feat: Add Opportunity Matrix with coordinate-based positioning

- Add 2x2 matrix visualization (Quick Wins, Critical, Strategic, Nice to Have)
- Position items based on frequency/effort coordinates with dots and leader lines
- Add L-shaped axes with arrows showing Frequency (X) and Effort (Y) directions
- Include unified coordinate grid overlay across all quadrants
- Add clickable subcode labels with hover effects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-25 12:29:01 +00:00
parent af82467595
commit c6beeaa3dc
2 changed files with 691 additions and 0 deletions

View File

@@ -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 (
<div className={`${bgColor} rounded-lg border ${borderColor} aspect-square relative overflow-hidden`}>
{/* Header */}
<div className="absolute top-2 left-2 right-2 flex items-center gap-1.5 z-10">
<div className={`p-1 ${iconBg} rounded`}>{icon}</div>
<span className={`text-xs font-bold ${textColor}`}>{title}</span>
<span className="ml-auto text-xs font-semibold text-gray-500 bg-white/60 px-1.5 py-0.5 rounded">
{items.length}
</span>
</div>
{/* Positioned items with dots and offset labels */}
{items.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-gray-400 italic">None</span>
</div>
) : (
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10">
{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 (
<g key={`${item.subcode}-${index}`}>
{/* Leader line */}
<line
x1={`${safeX}%`}
y1={`${safeY}%`}
x2={`${safeX + labelOffsetX * 0.6}%`}
y2={`${safeY + labelOffsetY * 0.3}%`}
stroke="currentColor"
strokeWidth="1"
opacity="0.3"
/>
{/* Dot at exact position */}
<circle
cx={`${safeX}%`}
cy={`${safeY}%`}
r="4"
fill="currentColor"
opacity="0.6"
/>
</g>
);
})}
</svg>
)}
{/* 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 (
<button
key={`label-${item.subcode}-${index}`}
onClick={() => onItemClick?.(item.subcode)}
className={`absolute px-1 py-0.5 text-[10px] font-mono font-bold rounded ${textColor} bg-white/90 hover:bg-white hover:scale-105 transition-all shadow-sm border border-current/20 z-20 whitespace-nowrap`}
style={{
left: `${safeX + labelOffsetX}%`,
top: `${safeY + labelOffsetY}%`,
transform: `translate(${safeX < 50 ? '0' : '-100%'}, -50%)`,
}}
>
{item.subcode}
</button>
);
})}
</div>
);
}
/**
* 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 (
<div className="bg-white rounded-xl p-4 shadow-md border-2 border-gray-200 max-w-2xl mx-auto">
<div className="flex items-center gap-2 mb-3">
<LayoutGrid className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-bold text-gray-900">Opportunity Matrix</h3>
<span className="ml-auto text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{totalItems} opportunities
</span>
</div>
{/* Matrix with L-shaped label area */}
<div className="flex">
{/* Y-axis label column */}
<div className="flex items-center justify-center w-8 mr-2">
<span className="-rotate-90 text-sm text-gray-600 font-semibold whitespace-nowrap">
Effort
</span>
</div>
{/* Main matrix area */}
<div className="flex-1 flex flex-col">
{/* Matrix with arrows */}
<div className="relative flex-1">
{/* Coordinate axes - L-shaped from bottom-left origin */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none overflow-visible"
style={{ zIndex: 5 }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<defs>
{/* Horizontal arrowhead - points right */}
<marker
id="arrowhead-right"
markerWidth="6"
markerHeight="6"
refX="5"
refY="3"
orient="0"
markerUnits="strokeWidth"
>
<path d="M0,0 L6,3 L0,6 Z" fill="#6b7280" />
</marker>
{/* Vertical arrowhead - points up (pre-rotated triangle) */}
<marker
id="arrowhead-up"
markerWidth="6"
markerHeight="6"
refX="3"
refY="1"
orient="0"
markerUnits="strokeWidth"
>
<path d="M0,6 L3,0 L6,6 Z" fill="#6b7280" />
</marker>
</defs>
{/* X-axis (horizontal - frequency increases right) */}
<line
x1="-1" y1="102"
x2="99" y2="102"
stroke="#6b7280"
strokeWidth="2.5"
markerEnd="url(#arrowhead-right)"
vectorEffect="non-scaling-stroke"
/>
{/* Y-axis (vertical - effort increases up) */}
<line
x1="-1" y1="102"
x2="-1" y2="1"
stroke="#6b7280"
strokeWidth="2.5"
markerEnd="url(#arrowhead-up)"
vectorEffect="non-scaling-stroke"
/>
</svg>
<div className="relative ml-3 mb-3">
{/* Unified coordinate grid overlay */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 2 }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Vertical grid lines */}
{[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((x) => (
<line
key={`grid-v-${x}`}
x1={x}
y1="0"
x2={x}
y2="100"
stroke="#9ca3af"
strokeWidth="1"
opacity="0.15"
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
))}
{/* Horizontal grid lines */}
{[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((y) => (
<line
key={`grid-h-${y}`}
x1="0"
y1={y}
x2="100"
y2={y}
stroke="#9ca3af"
strokeWidth="1"
opacity="0.15"
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
<div className="grid grid-cols-2 gap-2">
{/* Top Row: Simple solutions */}
<Quadrant
title="Nice to Have"
icon={<Clock className="w-3 h-3 text-gray-600" />}
items={matrix.nice_to_have}
bgColor="bg-gray-50"
borderColor="border-gray-200"
textColor="text-gray-700"
iconBg="bg-gray-200"
onItemClick={onSubcodeClick}
/>
<Quadrant
title="Quick Wins"
icon={<Zap className="w-3 h-3 text-green-600" />}
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 */}
<Quadrant
title="Strategic"
icon={<Target className="w-3 h-3 text-purple-600" />}
items={matrix.strategic}
bgColor="bg-purple-50"
borderColor="border-purple-200"
textColor="text-purple-700"
iconBg="bg-purple-200"
onItemClick={onSubcodeClick}
/>
<Quadrant
title="Critical"
icon={<Target className="w-3 h-3 text-red-600" />}
items={matrix.critical}
bgColor="bg-red-50"
borderColor="border-red-200"
textColor="text-red-700"
iconBg="bg-red-200"
onItemClick={onSubcodeClick}
/>
</div>
</div>
</div>
{/* X-axis label row */}
<div className="flex justify-center mt-4">
<span className="text-sm text-gray-600 font-semibold">
Frequency
</span>
</div>
</div>
</div>
{/* Legend */}
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-200 rounded" />
<span>Quick Wins: Fix first</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-200 rounded" />
<span>Critical: High impact</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-purple-200 rounded" />
<span>Strategic: Plan ahead</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-gray-200 rounded" />
<span>Nice to Have: Low priority</span>
</div>
</div>
</div>
);
}

View File

@@ -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<string, unknown>;
}
// ==================== 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<React.SetStateAction<ReviewIQFilters>>;
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<string, string> = {
'V+': '#22c55e',
'V0': '#eab308',
'V-': '#ef4444',
'V±': '#f97316',
};
export const DOMAIN_COLORS: Record<string, string> = {
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<string, string> = {
I1: '#fef08a', // Light yellow
I2: '#fbbf24', // Yellow
I3: '#f97316', // Orange
};
export const STATUS_COLORS: Record<string, string> = {
good: '#22c55e', // Green
warning: '#eab308', // Yellow
critical: '#ef4444', // Red
};
// ==================== Display Mappings ====================
export const VALENCE_LABELS: Record<string, string> = {
'V+': 'Positive',
'V0': 'Neutral',
'V-': 'Negative',
'V±': 'Mixed',
};
export const DOMAIN_LABELS: Record<string, string> = {
O: 'Offering',
P: 'People',
J: 'Journey',
E: 'Environment',
A: 'Access',
V: 'Value',
R: 'Relationship',
};
export const DOMAIN_OWNERS: Record<string, string> = {
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<string, string> = {
I1: 'Low',
I2: 'Medium',
I3: 'High',
};
export const COMPLEXITY_LABELS: Record<string, string> = {
simple: 'Quick Fix',
medium: 'Moderate Effort',
complex: 'Strategic Initiative',
};
// ==================== Domain Thresholds (from C2-KPI-Mapping-Guide) ====================
export const DOMAIN_THRESHOLDS: Record<string, { green: number; yellow: number }> = {
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<string, number> = {
O: 0.20,
P: 0.18,
J: 0.15,
E: 0.12,
A: 0.10,
V: 0.12,
R: 0.13,
};