diff --git a/web/components/reviewiq/insights/OpportunityMatrix.tsx b/web/components/reviewiq/insights/OpportunityMatrix.tsx new file mode 100644 index 0000000..ad068cb --- /dev/null +++ b/web/components/reviewiq/insights/OpportunityMatrix.tsx @@ -0,0 +1,325 @@ +'use client'; + +import { Target, Zap, Clock, LayoutGrid } from 'lucide-react'; +import type { OpportunityMatrix as OpportunityMatrixType, OpportunityItem } from '../types'; + +interface OpportunityMatrixProps { + matrix: OpportunityMatrixType | null; + onSubcodeClick?: (subcode: string) => void; +} + +interface QuadrantProps { + title: string; + icon: React.ReactNode; + items: OpportunityItem[]; + bgColor: string; + borderColor: string; + textColor: string; + iconBg: string; + onItemClick?: (subcode: string) => void; +} + +function Quadrant({ + title, + icon, + items, + bgColor, + borderColor, + textColor, + iconBg, + onItemClick, +}: QuadrantProps) { + return ( +
+ {/* Header */} +
+
{icon}
+ {title} + + {items.length} + +
+ + {/* Positioned items with dots and offset labels */} + {items.length === 0 ? ( +
+ None +
+ ) : ( + + {items.map((item, index) => { + // Constrain coordinates to safe zone + const safeX = 15 + item.x * 70; + const safeY = 30 + item.y * 55; + + // Offset label based on position to avoid clustering + // Items on left half get labels to the right, vice versa + const labelOffsetX = safeX < 50 ? 12 : -12; + const labelOffsetY = (index % 3 - 1) * 8; // Stagger vertically + + return ( + + {/* Leader line */} + + {/* Dot at exact position */} + + + ); + })} + + )} + {/* Clickable labels */} + {items.length > 0 && items.map((item, index) => { + const safeX = 15 + item.x * 70; + const safeY = 30 + item.y * 55; + const labelOffsetX = safeX < 50 ? 8 : -8; + const labelOffsetY = (index % 3 - 1) * 6; + const anchorX = safeX < 50 ? 'left-0' : 'right-0'; + + return ( + + ); + })} +
+ ); +} + +/** + * 2x2 Opportunity Matrix visualization. + * Shows issues categorized by frequency vs complexity with coordinate positioning. + */ +export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixProps) { + if (!matrix) { + return null; + } + + const totalItems = + matrix.quick_wins.length + + matrix.critical.length + + matrix.nice_to_have.length + + matrix.strategic.length; + + if (totalItems === 0) { + return null; + } + + return ( +
+
+ +

Opportunity Matrix

+ + {totalItems} opportunities + +
+ + {/* Matrix with L-shaped label area */} +
+ {/* Y-axis label column */} +
+ + Effort → + +
+ + {/* Main matrix area */} +
+ {/* Matrix with arrows */} +
+ {/* Coordinate axes - L-shaped from bottom-left origin */} + + + {/* Horizontal arrowhead - points right */} + + + + {/* Vertical arrowhead - points up (pre-rotated triangle) */} + + + + + {/* X-axis (horizontal - frequency increases right) */} + + {/* Y-axis (vertical - effort increases up) */} + + + +
+ {/* Unified coordinate grid overlay */} + + {/* Vertical grid lines */} + {[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((x) => ( + + ))} + {/* Horizontal grid lines */} + {[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((y) => ( + + ))} + + +
+ {/* Top Row: Simple solutions */} + } + items={matrix.nice_to_have} + bgColor="bg-gray-50" + borderColor="border-gray-200" + textColor="text-gray-700" + iconBg="bg-gray-200" + onItemClick={onSubcodeClick} + /> + } + items={matrix.quick_wins} + bgColor="bg-green-50" + borderColor="border-green-200" + textColor="text-green-700" + iconBg="bg-green-200" + onItemClick={onSubcodeClick} + /> + + {/* Bottom Row: Complex solutions */} + } + items={matrix.strategic} + bgColor="bg-purple-50" + borderColor="border-purple-200" + textColor="text-purple-700" + iconBg="bg-purple-200" + onItemClick={onSubcodeClick} + /> + } + items={matrix.critical} + bgColor="bg-red-50" + borderColor="border-red-200" + textColor="text-red-700" + iconBg="bg-red-200" + onItemClick={onSubcodeClick} + /> +
+
+
+ + {/* X-axis label row */} +
+ + Frequency → + +
+
+
+ + {/* Legend */} +
+
+
+ Quick Wins: Fix first +
+
+
+ Critical: High impact +
+
+
+ Strategic: Plan ahead +
+
+
+ Nice to Have: Low priority +
+
+
+ ); +} diff --git a/web/components/reviewiq/types.ts b/web/components/reviewiq/types.ts new file mode 100644 index 0000000..e9c5913 --- /dev/null +++ b/web/components/reviewiq/types.ts @@ -0,0 +1,366 @@ +/** + * TypeScript types for ReviewIQ Dashboard. + */ + +// ==================== API Response Types ==================== + +export interface OverviewStats { + total_reviews: number; + total_spans: number; + open_issues: number; + avg_rating: number | null; + positive_count: number; + negative_count: number; + neutral_count: number; + mixed_count: number; +} + +export interface SentimentDataPoint { + valence: string; + count: number; // Span count (mentions) + review_count: number; // Distinct reviews + percentage: number; // Based on review_count +} + +export interface SentimentTrendPoint { + period: string; + positive: number; + negative: number; + neutral: number; + mixed: number; +} + +export interface SentimentData { + distribution: SentimentDataPoint[]; + trend: SentimentTrendPoint[]; +} + +export interface URTDomainPoint { + domain: string; + domain_name: string; + count: number; // Span count (mentions) + review_count: number; // Distinct reviews affected + percentage: number; // Based on review_count + positive_count: number; // Positive spans + negative_count: number; // Negative spans + neutral_count: number; // Neutral spans + positive_reviews: number; // Reviews with positive sentiment + negative_reviews: number; // Reviews with negative sentiment +} + +export interface IntensityPoint { + domain: string; + intensity: string; + count: number; +} + +export interface URTData { + domains: URTDomainPoint[]; + intensity_heatmap: IntensityPoint[]; +} + +// ==================== Domain Scores ==================== + +export interface DomainScore { + domain: string; + name: string; + score: number; + status: 'good' | 'warning' | 'critical'; + trend: string | null; + positive_count: number; + negative_count: number; + total_count: number; +} + +// ==================== Insights ==================== + +export interface StrengthItem { + rank: number; + subcode: string; + subcode_name: string; + domain: string; + domain_name: string; + positive_percentage: number; + span_count: number; + marketing_angle: string | null; +} + +export interface WeaknessItem { + rank: number; + issue_id: string | null; + subcode: string; + subcode_name: string; + domain: string; + domain_name: string; + negative_percentage: number; + span_count: number; + intensity: string | null; + solution: string | null; + solution_complexity: string | null; + projected_rating_impact: number | null; + owner: string | null; +} + +export interface RatingSimulator { + current_rating: number; + if_fix_top_1: number | null; + if_fix_top_3: number | null; + potential_gain: number; +} + +export interface OpportunityItem { + subcode: string; + x: number; // 0-1, frequency position within quadrant + y: number; // 0-1, effort position within quadrant +} + +export interface OpportunityMatrix { + quick_wins: OpportunityItem[]; + critical: OpportunityItem[]; + nice_to_have: OpportunityItem[]; + strategic: OpportunityItem[]; +} + +export interface Insights { + strengths: StrengthItem[]; + weaknesses: WeaknessItem[]; + rating_simulator: RatingSimulator | null; + opportunity_matrix: OpportunityMatrix | null; + executive_summary: string; +} + +// ==================== Issues (Enriched) ==================== + +export interface IssueItem { + issue_id: string; + primary_subcode: string; + subcode_name: string | null; + subcode_definition: string | null; + solution: string | null; + solution_complexity: string | null; + domain: string; + domain_name: string | null; + category_name: string | null; + default_owner: string | null; + negative_example: string | null; + entity: string | null; + state: string; + priority_score: number; + span_count: number; + max_intensity: string | null; + created_at: string | null; +} + +export interface PaginatedIssues { + items: IssueItem[]; + total: number; + page: number; + page_size: number; +} + +// ==================== Spans ==================== + +export interface SpanItem { + span_id: string; + span_text: string; + urt_primary: string | null; + valence: string | null; + intensity: string | null; + review_time: string | null; + source_review_id: string | null; + entity: string | null; +} + +export interface PaginatedSpans { + items: SpanItem[]; + total: number; + page: number; + page_size: number; +} + +// ==================== Full Review (Drill-Down) ==================== + +export interface ReviewSpan { + span_id: string; + span_text: string; + start_offset: number | null; + end_offset: number | null; + urt_primary: string | null; + urt_secondary: string[] | null; + valence: string | null; + intensity: string | null; + entity: string | null; +} + +export interface FullReview { + review_id: string; + source: string; + rating: number | null; + review_text: string | null; + text_normalized: string | null; // Text used for span offset calculation + review_time: string | null; + author_name: string | null; + author_url: string | null; + review_url: string | null; + business_name: string | null; + urt_primary: string | null; + urt_secondary: string[] | null; + spans: ReviewSpan[]; +} + +// ==================== Timeline ==================== + +export interface TimelinePoint { + date: string; + review_count: number; + span_count: number; + avg_rating: number | null; + positive_count: number; + negative_count: number; +} + +// ==================== Main Response ==================== + +export interface ReviewIQAnalyticsResponse { + overview: OverviewStats; + sentiment: SentimentData; + urt: URTData; + domain_scores: DomainScore[]; + overall_experience_index: number | null; + insights: Insights; + issues: PaginatedIssues; + spans: PaginatedSpans; + timeline: TimelinePoint[]; + filters_applied: Record; +} + +// ==================== Filter Types ==================== + +export type TimeRange = '7d' | '14d' | '30d' | '90d' | '1y' | 'all'; +export type Sentiment = 'positive' | 'neutral' | 'negative'; +export type URTDomain = 'O' | 'P' | 'J' | 'E' | 'A' | 'V' | 'R'; +export type Intensity = 'I1' | 'I2' | 'I3'; +export type SentimentView = 'all' | 'positive' | 'negative' | 'mixed'; + +export interface ReviewIQFilters { + timeRange: TimeRange; + sentiment: Sentiment[]; + urtDomain: URTDomain | null; + intensity: Intensity[]; + brushRange: { start: string; end: string } | null; +} + +export interface ReviewIQFilterContextValue { + filters: ReviewIQFilters; + setFilters: React.Dispatch>; + toggleSentiment: (s: Sentiment) => void; + setURTDomain: (domain: URTDomain | null) => void; + toggleIntensity: (i: Intensity) => void; + setTimeRange: (range: TimeRange) => void; + setBrushRange: (range: { start: string; end: string } | null) => void; + clearFilters: () => void; + hasActiveFilters: boolean; +} + +// ==================== Chart Colors ==================== + +export const SENTIMENT_COLORS = { + positive: '#22c55e', + neutral: '#eab308', + negative: '#ef4444', + mixed: '#f97316', +} as const; + +export const VALENCE_COLORS: Record = { + 'V+': '#22c55e', + 'V0': '#eab308', + 'V-': '#ef4444', + 'V±': '#f97316', +}; + +export const DOMAIN_COLORS: Record = { + O: '#f97316', // Offering - orange + P: '#3b82f6', // People - blue + J: '#8b5cf6', // Journey - purple + E: '#06b6d4', // Environment - cyan + A: '#10b981', // Access - green + V: '#ec4899', // Value - pink + R: '#f59e0b', // Relationship - amber +}; + +export const INTENSITY_COLORS: Record = { + I1: '#fef08a', // Light yellow + I2: '#fbbf24', // Yellow + I3: '#f97316', // Orange +}; + +export const STATUS_COLORS: Record = { + good: '#22c55e', // Green + warning: '#eab308', // Yellow + critical: '#ef4444', // Red +}; + +// ==================== Display Mappings ==================== + +export const VALENCE_LABELS: Record = { + 'V+': 'Positive', + 'V0': 'Neutral', + 'V-': 'Negative', + 'V±': 'Mixed', +}; + +export const DOMAIN_LABELS: Record = { + O: 'Offering', + P: 'People', + J: 'Journey', + E: 'Environment', + A: 'Access', + V: 'Value', + R: 'Relationship', +}; + +export const DOMAIN_OWNERS: Record = { + O: 'Operations / Product', + P: 'HR / Training', + J: 'Operations / Process', + E: 'Facilities / IT', + A: 'Compliance / Design', + V: 'Finance / Pricing', + R: 'Leadership / CX', +}; + +export const INTENSITY_LABELS: Record = { + I1: 'Low', + I2: 'Medium', + I3: 'High', +}; + +export const COMPLEXITY_LABELS: Record = { + simple: 'Quick Fix', + medium: 'Moderate Effort', + complex: 'Strategic Initiative', +}; + +// ==================== Domain Thresholds (from C2-KPI-Mapping-Guide) ==================== + +export const DOMAIN_THRESHOLDS: Record = { + O: { green: 80, yellow: 60 }, + P: { green: 85, yellow: 70 }, + J: { green: 75, yellow: 55 }, + E: { green: 80, yellow: 65 }, + A: { green: 85, yellow: 70 }, + V: { green: 70, yellow: 50 }, + R: { green: 80, yellow: 60 }, +}; + +// ==================== OEI Weights (from C2-KPI-Mapping-Guide) ==================== + +export const DOMAIN_WEIGHTS: Record = { + O: 0.20, + P: 0.18, + J: 0.15, + E: 0.12, + A: 0.10, + V: 0.12, + R: 0.13, +};