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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useParams, useNavigate } from '@tanstack/react-router';
|
||||
import { useGlobalEvent } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { MacroImpactRecord } from '../api/hooks';
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
high: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
|
||||
moderate: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
low: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
};
|
||||
|
||||
const impactCols: Column<MacroImpactRecord>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', render: (r) => <span className="font-mono font-semibold text-brand-300">{r.ticker}</span> },
|
||||
{ key: 'macro_impact_score', header: 'Impact Score', render: (r) => <ConfidenceBar value={r.macro_impact_score} /> },
|
||||
{ key: 'impact_direction', header: 'Direction', render: (r) => <StatusBadge status={r.impact_direction} /> },
|
||||
{
|
||||
key: 'contributing_factors',
|
||||
header: 'Contributing Factors',
|
||||
render: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.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>
|
||||
),
|
||||
},
|
||||
{ key: 'confidence', header: 'Confidence', render: (r) => <ConfidenceBar value={r.confidence} /> },
|
||||
{ key: 'computed_at', header: 'Computed', render: (r) => <span className="text-xs">{new Date(r.computed_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
export function GlobalEventDetailPage() {
|
||||
const { id } = useParams({ from: '/macro/events/$id' });
|
||||
const navigate = useNavigate();
|
||||
const { data: event, isLoading } = useGlobalEvent(id);
|
||||
|
||||
if (isLoading || !event) return <LoadingSpinner />;
|
||||
|
||||
const sevCls = severityColors[event.severity] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Global Event</h1>
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${sevCls}`}>{event.severity}</span>
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{event.estimated_duration.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Summary</h2>
|
||||
<p className="text-sm text-gray-200">{event.summary}</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-gray-500">Impact Types</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{event.event_types.map((t) => (
|
||||
<span key={t} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{t.replace(/_/g, ' ')}</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Regions</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{event.affected_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">Sectors</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{event.affected_sectors.map((s) => (
|
||||
<span key={s} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{s}</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Confidence</dt>
|
||||
<dd><ConfidenceBar value={event.confidence} /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{event.key_facts && event.key_facts.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Key Facts</h2>
|
||||
<ul className="ml-4 list-disc text-sm text-gray-300">
|
||||
{event.key_facts.map((f, i) => <li key={i}>{f}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Affected Companies ({event.impacts?.length ?? 0})</h2>
|
||||
<DataTable<MacroImpactRecord>
|
||||
data={event.impacts ?? []}
|
||||
columns={impactCols}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/companies/$id', params: { id: row.company_id } })}
|
||||
filterFn={(row, q) => row.ticker.toLowerCase().includes(q.toLowerCase())}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useGlobalEvents } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner } from '../components/ui';
|
||||
import type { GlobalEvent } from '../api/hooks';
|
||||
|
||||
const SEVERITIES = ['low', 'moderate', 'high', 'critical'];
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
high: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
|
||||
moderate: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
low: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const cls = severityColors[severity] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Column<GlobalEvent>[] = [
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'Summary',
|
||||
render: (r) => <span className="line-clamp-1 max-w-sm">{r.summary}</span>,
|
||||
},
|
||||
{
|
||||
key: 'event_types',
|
||||
header: 'Impact Types',
|
||||
render: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.event_types.map((t) => (
|
||||
<span key={t} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{t.replace(/_/g, ' ')}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
render: (r) => <SeverityBadge severity={r.severity} />,
|
||||
},
|
||||
{
|
||||
key: 'affected_regions',
|
||||
header: 'Regions',
|
||||
render: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.affected_regions.slice(0, 4).map((reg) => (
|
||||
<span key={reg} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{reg}</span>
|
||||
))}
|
||||
{r.affected_regions.length > 4 && <span className="text-[10px] text-gray-500">+{r.affected_regions.length - 4}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'affected_sectors',
|
||||
header: 'Sectors',
|
||||
render: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.affected_sectors.slice(0, 3).map((s) => (
|
||||
<span key={s} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{s}</span>
|
||||
))}
|
||||
{r.affected_sectors.length > 3 && <span className="text-[10px] text-gray-500">+{r.affected_sectors.length - 3}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Event Date',
|
||||
render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleDateString()}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function GlobalEventsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [severity, setSeverity] = useState('');
|
||||
const { data, isLoading, error } = useGlobalEvents({
|
||||
severity: severity || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="text-red-400">Failed to load global events</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Global Events</h1>
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Severity filter">
|
||||
<button
|
||||
onClick={() => setSeverity('')}
|
||||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${!severity ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{SEVERITIES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeverity(s)}
|
||||
className={`px-2 py-1 text-xs font-medium capitalize last:rounded-r-md ${severity === s ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DataTable<GlobalEvent>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/macro/events/$id', params: { id: row.id } })}
|
||||
filterFn={(row, q) => {
|
||||
const lq = q.toLowerCase();
|
||||
return row.summary.toLowerCase().includes(lq) || row.event_types.some((t) => t.toLowerCase().includes(lq));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useParams, Link } from '@tanstack/react-router';
|
||||
import { useRecommendation } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
@@ -67,23 +67,36 @@ export function RecommendationDetailPage() {
|
||||
<p className="text-sm text-gray-500">No evidence linked</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rec.evidence.map((ev) => (
|
||||
<div key={ev.id} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ev.evidence_type} />
|
||||
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
|
||||
{rec.evidence.map((ev) => {
|
||||
const isMacro = ev.document_type === 'macro_event' || ev.evidence_type === 'macro_event';
|
||||
return (
|
||||
<div key={ev.id} className={`rounded-lg border p-3 ${isMacro ? 'border-purple-700/50 bg-purple-900/10' : 'border-surface-700 bg-surface-950'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMacro && (
|
||||
<Link
|
||||
to="/macro/events/$id"
|
||||
params={{ id: ev.document_id }}
|
||||
className="rounded bg-purple-900/40 border border-purple-700/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-400 hover:text-purple-300"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
MACRO ↗
|
||||
</Link>
|
||||
)}
|
||||
<StatusBadge status={ev.evidence_type} />
|
||||
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type}</span>
|
||||
<span>{ev.source_type}</span>
|
||||
{ev.publisher && <span>{ev.publisher}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type}</span>
|
||||
<span>{ev.source_type}</span>
|
||||
{ev.publisher && <span>{ev.publisher}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
usePendingApprovals,
|
||||
useReviewApproval,
|
||||
useActiveLockouts,
|
||||
useMacroStatus,
|
||||
useToggleMacro,
|
||||
useCompetitiveStatus,
|
||||
useToggleCompetitive,
|
||||
} from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
@@ -12,9 +16,15 @@ export function TradingPage() {
|
||||
const { data: config, isLoading: configLoading } = useTradingConfig();
|
||||
const { data: approvals } = usePendingApprovals();
|
||||
const { data: lockouts } = useActiveLockouts();
|
||||
const { data: macroStatus } = useMacroStatus();
|
||||
const { data: competitiveStatus } = useCompetitiveStatus();
|
||||
const setMode = useSetTradingMode();
|
||||
const reviewApproval = useReviewApproval();
|
||||
const toggleMacro = useToggleMacro();
|
||||
const toggleCompetitive = useToggleCompetitive();
|
||||
const [confirmMode, setConfirmMode] = useState<string | null>(null);
|
||||
const [confirmMacroToggle, setConfirmMacroToggle] = useState(false);
|
||||
const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false);
|
||||
|
||||
if (configLoading) return <LoadingSpinner />;
|
||||
|
||||
@@ -73,6 +83,126 @@ export function TradingPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Macro Signal Layer Toggle */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Macro Signal Layer</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setConfirmMacroToggle(true)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
|
||||
macroStatus?.enabled ? 'bg-brand-600' : 'bg-surface-700'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={macroStatus?.enabled ?? false}
|
||||
aria-label="Toggle macro signal layer"
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
|
||||
macroStatus?.enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-300">
|
||||
{macroStatus?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{macroStatus?.toggled_at && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Last changed: {new Date(macroStatus.toggled_at).toLocaleString()}
|
||||
{macroStatus.toggled_by && ` by ${macroStatus.toggled_by}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for macro toggle */}
|
||||
{confirmMacroToggle && (
|
||||
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
|
||||
<p className="text-sm text-orange-300">
|
||||
Are you sure you want to {macroStatus?.enabled ? 'disable' : 'enable'} the macro signal layer?
|
||||
{macroStatus?.enabled
|
||||
? ' Disabling will exclude macro signals from trend summaries and recommendations.'
|
||||
: ' Enabling will include global event macro signals in trend summaries and recommendations.'}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleMacro.mutate(!macroStatus?.enabled);
|
||||
setConfirmMacroToggle(false);
|
||||
}}
|
||||
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmMacroToggle(false)}
|
||||
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Competitive Signal Layer Toggle */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Competitive Signal Layer</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setConfirmCompetitiveToggle(true)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
|
||||
competitiveStatus?.enabled ? 'bg-brand-600' : 'bg-surface-700'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={competitiveStatus?.enabled ?? false}
|
||||
aria-label="Toggle competitive signal layer"
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
|
||||
competitiveStatus?.enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-300">
|
||||
{competitiveStatus?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{competitiveStatus?.toggled_at && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Last changed: {new Date(competitiveStatus.toggled_at).toLocaleString()}
|
||||
{competitiveStatus.toggled_by && ` by ${competitiveStatus.toggled_by}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for competitive toggle */}
|
||||
{confirmCompetitiveToggle && (
|
||||
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
|
||||
<p className="text-sm text-orange-300">
|
||||
Are you sure you want to {competitiveStatus?.enabled ? 'disable' : 'enable'} the competitive signal layer?
|
||||
{competitiveStatus?.enabled
|
||||
? ' Disabling will exclude historical pattern and competitive signals from trend summaries and recommendations.'
|
||||
: ' Enabling will include historical pattern and competitive signals in trend summaries and recommendations.'}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleCompetitive.mutate(!competitiveStatus?.enabled);
|
||||
setConfirmCompetitiveToggle(false);
|
||||
}}
|
||||
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCompetitiveToggle(false)}
|
||||
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Pending Approvals */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useTrend, useTrendEvidence } from '../api/hooks';
|
||||
import { useState } from 'react';
|
||||
import { useTrend, useTrendEvidence, useTrendProjection } from '../api/hooks';
|
||||
import { TrendArrow, ConfidenceBar, StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function TrendDetailPage() {
|
||||
const { id } = useParams({ from: '/trends/$id' });
|
||||
const { data: trend, isLoading } = useTrend(id);
|
||||
const { data: evidenceData } = useTrendEvidence(id);
|
||||
const { data: projection } = useTrendProjection(id);
|
||||
|
||||
if (isLoading || !trend) return <LoadingSpinner />;
|
||||
|
||||
@@ -68,6 +70,9 @@ export function TrendDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Trend Projection (Task 17.5) */}
|
||||
{projection && <TrendProjectionPanel projection={projection} />}
|
||||
|
||||
{/* Evidence drill-down */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Contributing Evidence ({evidence.length})</h2>
|
||||
@@ -75,32 +80,101 @@ export function TrendDetailPage() {
|
||||
<p className="text-sm text-gray-500">No evidence records</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{evidence.map((ev, i) => (
|
||||
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={String(ev.evidence_type)} />
|
||||
<span className="text-sm text-gray-200">{String(ev.title ?? 'Untitled')}</span>
|
||||
{evidence.map((ev, i) => {
|
||||
const isMacro = String(ev.document_type) === 'macro_event' || String(ev.evidence_type) === 'macro_event';
|
||||
const isPattern = String(ev.evidence_type) === 'pattern_signal' || String(ev.document_type) === 'pattern_signal';
|
||||
const isCompetitive = String(ev.evidence_type) === 'competitive_signal' || String(ev.document_type) === 'competitive_signal';
|
||||
|
||||
let borderClass = 'border-surface-700 bg-surface-950';
|
||||
if (isMacro) borderClass = 'border-purple-700/50 bg-purple-900/10';
|
||||
else if (isCompetitive) borderClass = 'border-cyan-700/50 bg-cyan-900/10';
|
||||
else if (isPattern) borderClass = 'border-amber-700/50 bg-amber-900/10';
|
||||
|
||||
return (
|
||||
<div key={i} className={`rounded-lg border p-3 ${borderClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMacro && <span className="rounded bg-purple-900/40 border border-purple-700/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-400">MACRO</span>}
|
||||
{isPattern && <span className="rounded bg-amber-900/40 border border-amber-700/50 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">PATTERN</span>}
|
||||
{isCompetitive && <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>}
|
||||
<StatusBadge status={String(ev.evidence_type)} />
|
||||
<span className="text-sm text-gray-200">{String(ev.title ?? 'Untitled')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{String(ev.document_type)}</span>
|
||||
<span>{String(ev.source_type)}</span>
|
||||
{ev.publisher ? <span>{String(ev.publisher)}</span> : null}
|
||||
{ev.published_at ? <span>{new Date(String(ev.published_at)).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
{ev.intelligence ? (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<span className="text-gray-500">Summary: </span>
|
||||
{String((ev.intelligence as Record<string, unknown>).summary ?? '—')}
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{String(ev.document_type)}</span>
|
||||
<span>{String(ev.source_type)}</span>
|
||||
{ev.publisher ? <span>{String(ev.publisher)}</span> : null}
|
||||
{ev.published_at ? <span>{new Date(String(ev.published_at)).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{ev.intelligence ? (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<span className="text-gray-500">Summary: </span>
|
||||
{String((ev.intelligence as Record<string, unknown>).summary ?? '—')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendProjectionPanel({ projection }: { projection: import('../api/hooks').TrendProjection }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className={projection.diverges_from_current ? 'border-yellow-700/50' : ''}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-400">
|
||||
Trend Projection
|
||||
{projection.diverges_from_current && (
|
||||
<span className="ml-2 rounded bg-yellow-900/40 border border-yellow-700/50 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">DIVERGENCE</span>
|
||||
)}
|
||||
</h2>
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{projection.projection_horizon}</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-gray-500">Projected Direction</dt>
|
||||
<dd className="flex items-center gap-1 text-gray-200">
|
||||
<TrendArrow direction={projection.projected_direction} /> {projection.projected_direction}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Projected Strength</dt>
|
||||
<dd><ConfidenceBar value={projection.projected_strength} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Projection Confidence</dt>
|
||||
<dd><ConfidenceBar value={projection.projected_confidence} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Macro Contribution</dt>
|
||||
<dd className="text-gray-200">{(projection.macro_contribution_pct * 100).toFixed(0)}%</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{projection.driving_factors.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-brand-400 hover:underline"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} driving factors ({projection.driving_factors.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="mt-2 ml-4 list-disc text-sm text-gray-300">
|
||||
{projection.driving_factors.map((f, i) => <li key={i}>{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user