Files
whyrating-engine-legacy/web/components/reviewiq/ReviewIQDashboard.tsx
Alejandro Gutiérrez d5ef13b58e feat(frontend): Add BusinessReport component for 6-section €60 report
- Create BusinessReport.tsx with 6 sections:
  1. Executive Summary (health score, rating, momentum)
  2. Risk Scorecard (indicators with colors/trends)
  3. Critical Issues (evidence, solutions, timelines)
  4. Strengths to Protect (quotes, leverage actions)
  5. Action Matrix (effort/impact quadrants)
  6. 90-Day Tracking (KPI targets table)

- Update types.ts with new interfaces:
  - SynthesisV2 for new report format
  - LegacySynthesis for backwards compatibility
  - Type guard isSynthesisV2() for runtime detection

- Update ReportTab to auto-detect synthesis version
- Update AnalystReport, ReviewIQDashboard, StoryView
  for backwards compatibility with union type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:36:05 +00:00

190 lines
5.7 KiB
TypeScript

'use client';
import { useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { ReviewIQFilterProvider, useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
import { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton';
import { FilterBar } from './FilterBar';
import { SentimentPie } from './charts/SentimentPie';
import { IntensityHeatmap } from './charts/IntensityHeatmap';
import { TimelineChart } from './charts/TimelineChart';
import { IssuesTable } from './tables/IssuesTable';
import { SpansTable } from './tables/SpansTable';
import { OpportunityMatrix } from './insights/OpportunityMatrix';
import { ExplorerView } from './ExplorerView';
import type { URTDomain, Synthesis, LegacySynthesis } from './types';
import { isSynthesisV2 } from './types';
// Helper to extract legacy fields from either synthesis format
function getLegacyInsight(synthesis: Synthesis | null | undefined, field: keyof LegacySynthesis): string | undefined {
if (!synthesis) return undefined;
if (isSynthesisV2(synthesis)) {
// V2 doesn't have these fields, return undefined
return undefined;
}
return (synthesis as LegacySynthesis)[field] as string | undefined;
}
interface ReviewIQDashboardProps {
jobId?: string | null;
businessId?: string | null;
}
/**
* Inner dashboard component that uses the filter context.
* Shows data exploration view with charts, tables, and trend explorer.
*/
function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
const { filters, setURTDomain } = useReviewIQFilters();
const [issuesPage, setIssuesPage] = useState(1);
const [spansPage, setSpansPage] = useState(1);
const { data, loading, error, refetch } = useReviewIQAnalytics({
jobId,
businessId,
filters,
issuesPage,
issuesPageSize: 10,
spansPage,
spansPageSize: 10,
});
// No job selected
if (!jobId && !businessId) {
return <DashboardEmpty />;
}
// Loading state
if (loading && !data) {
return <DashboardSkeleton />;
}
// Error state
if (error) {
return <DashboardError message={error} onRetry={refetch} />;
}
// No data
if (!data) {
return <DashboardEmpty />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">Data Explorer</h1>
<p className="text-sm text-gray-500">
{data.overview.total_reviews.toLocaleString()} reviews · {data.overview.total_spans.toLocaleString()} insights
</p>
</div>
<button
onClick={refetch}
disabled={loading}
className="px-4 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Data View */}
<DataView
data={data}
filters={filters}
setURTDomain={setURTDomain}
issuesPage={issuesPage}
spansPage={spansPage}
setIssuesPage={setIssuesPage}
setSpansPage={setSpansPage}
jobId={jobId || undefined}
businessId={businessId || undefined}
/>
</div>
);
}
/**
* Data view - the detailed charts and tables
*/
function DataView({
data,
filters,
setURTDomain,
issuesPage,
spansPage,
setIssuesPage,
setSpansPage,
jobId,
businessId,
}: {
data: NonNullable<ReturnType<typeof useReviewIQAnalytics>['data']>;
filters: ReturnType<typeof useReviewIQFilters>['filters'];
setURTDomain: ReturnType<typeof useReviewIQFilters>['setURTDomain'];
issuesPage: number;
spansPage: number;
setIssuesPage: (page: number) => void;
setSpansPage: (page: number) => void;
jobId?: string;
businessId?: string;
}) {
const handleDomainClick = (domain: URTDomain) => {
setURTDomain(filters.urtDomain === domain ? null : domain);
};
return (
<>
{/* Filters */}
<FilterBar />
{/* Sentiment + Categories */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SentimentPie
data={data.sentiment.distribution}
insight={getLegacyInsight(data.synthesis, 'sentiment_headline')}
/>
<IntensityHeatmap
data={data.urt.domains}
insight={getLegacyInsight(data.synthesis, 'category_headline')}
highlightDomain={getLegacyInsight(data.synthesis, 'primary_problem_code')?.charAt(0)}
/>
</div>
{/* Opportunity Matrix */}
<OpportunityMatrix matrix={data.insights.opportunity_matrix} />
{/* Timeline */}
<TimelineChart
data={data.timeline}
insight={getLegacyInsight(data.synthesis, 'timeline_headline')}
granularity={(data.filters_applied?.granularity as 'day' | 'week' | 'month' | 'year') || 'week'}
/>
{/* Tables */}
<div className="grid lg:grid-cols-2 gap-6">
<IssuesTable issues={data.issues} onPageChange={setIssuesPage} />
<SpansTable spans={data.spans} onPageChange={setSpansPage} />
</div>
{/* Trend Explorer */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Trend Explorer</h2>
<ExplorerView jobId={jobId} businessId={businessId} />
</div>
</>
);
}
/**
* Main ReviewIQ Dashboard with filter context provider.
*/
export function ReviewIQDashboard({ jobId, businessId }: ReviewIQDashboardProps) {
return (
<ReviewIQFilterProvider>
<ReviewIQDashboardInner jobId={jobId} businessId={businessId} />
</ReviewIQFilterProvider>
);
}