Files
whyrating-engine-legacy/web/components/reviewiq/insights/OpportunityMatrix.tsx
Alejandro Gutiérrez c6beeaa3dc 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>
2026-01-25 12:29:01 +00:00

326 lines
11 KiB
TypeScript

'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>
);
}