- 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>
326 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|