Frontend: - Add Synthesis type with action plan, insights, annotations - ExecutiveSummary: Accept synthesis prop for AI narrative - SentimentPie: Accept insight prop for contextual explanation - IntensityHeatmap: Accept insight + highlightDomain props - TimelineChart: Accept insight + annotations props - All components gracefully degrade when synthesis is null Backend: - Add Stage 4: Synthesize for generating AI narratives - Gathers context from classified spans - Generates executive narrative, section insights, action plan - Produces timeline annotations and marketing angles - Stores synthesis in pipeline.executions table Components show AI insights with purple gradient styling when available, fall back to existing behavior when synthesis is not yet generated. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1203 lines
40 KiB
TypeScript
1203 lines
40 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState, useRef, useEffect } from 'react';
|
|
import { Target, Zap, Clock, LayoutGrid, TrendingUp, Users, Wrench, X, ChevronRight, MessageSquare, Star, Maximize2 } from 'lucide-react';
|
|
import type { OpportunityMatrix as OpportunityMatrixType, OpportunityItem, OpportunitySpan } from '../types';
|
|
import { ReviewModal } from '../tables/ReviewModal';
|
|
import { DOMAIN_COLORS, COMPLEXITY_LABELS } 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?: (item: OpportunityItem) => void;
|
|
onItemHover?: (item: OpportunityItem | null) => void;
|
|
selectedItem?: OpportunityItem | null;
|
|
}
|
|
|
|
type ConnectorSide = 'top' | 'bottom' | 'left' | 'right';
|
|
|
|
interface LabelPosition {
|
|
x: number; // Label center X
|
|
y: number; // Label center Y
|
|
connector: ConnectorSide; // Which side the edge connects to
|
|
nodeX: number;
|
|
nodeY: number;
|
|
}
|
|
|
|
// Get the connector point on the label edge based on connector side
|
|
function getConnectorPoint(pos: LabelPosition): { x: number; y: number } {
|
|
const halfW = LABEL_WIDTH / 2;
|
|
const halfH = LABEL_HEIGHT / 2;
|
|
|
|
switch (pos.connector) {
|
|
case 'top':
|
|
return { x: pos.x, y: pos.y - halfH };
|
|
case 'bottom':
|
|
return { x: pos.x, y: pos.y + halfH };
|
|
case 'left':
|
|
return { x: pos.x - halfW, y: pos.y };
|
|
case 'right':
|
|
return { x: pos.x + halfW, y: pos.y };
|
|
}
|
|
}
|
|
|
|
// Determine the best connector side based on angle from node to label
|
|
function getConnectorSide(nodeX: number, nodeY: number, labelX: number, labelY: number): ConnectorSide {
|
|
const angle = Math.atan2(labelY - nodeY, labelX - nodeX) * (180 / Math.PI);
|
|
|
|
// Angle ranges for each connector:
|
|
// Right: -45 to 45 (label is to the right of node, connect on left side of label)
|
|
// Bottom: 45 to 135 (label is below node, connect on top of label)
|
|
// Left: 135 to 180 or -180 to -135 (label is to the left, connect on right side)
|
|
// Top: -135 to -45 (label is above node, connect on bottom of label)
|
|
|
|
if (angle >= -45 && angle < 45) {
|
|
return 'left'; // Label is to the right, connect on its left side
|
|
} else if (angle >= 45 && angle < 135) {
|
|
return 'top'; // Label is below, connect on its top
|
|
} else if (angle >= -135 && angle < -45) {
|
|
return 'bottom'; // Label is above, connect on its bottom
|
|
} else {
|
|
return 'right'; // Label is to the left, connect on its right side
|
|
}
|
|
}
|
|
|
|
// Estimated label dimensions in percentage units
|
|
const LABEL_WIDTH = 18; // ~18% width for truncated names (up to 14 chars)
|
|
const LABEL_HEIGHT = 6; // ~6% height
|
|
|
|
// Check if two rectangles overlap (with padding)
|
|
function rectsOverlap(
|
|
r1: { x: number; y: number; w: number; h: number },
|
|
r2: { x: number; y: number; w: number; h: number },
|
|
padding = 1
|
|
): boolean {
|
|
return !(
|
|
r1.x + r1.w + padding < r2.x ||
|
|
r2.x + r2.w + padding < r1.x ||
|
|
r1.y + r1.h + padding < r2.y ||
|
|
r2.y + r2.h + padding < r1.y
|
|
);
|
|
}
|
|
|
|
// Get label bounding box based on center position
|
|
function getLabelRect(pos: { x: number; y: number }) {
|
|
const w = LABEL_WIDTH;
|
|
const h = LABEL_HEIGHT;
|
|
const x = pos.x - w / 2;
|
|
const y = pos.y - h / 2;
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
// Check if two line segments intersect
|
|
function linesIntersect(
|
|
p1: { x: number; y: number },
|
|
p2: { x: number; y: number },
|
|
p3: { x: number; y: number },
|
|
p4: { x: number; y: number }
|
|
): boolean {
|
|
const ccw = (A: { x: number; y: number }, B: { x: number; y: number }, C: { x: number; y: number }) => {
|
|
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
|
|
};
|
|
|
|
// Check if segments share an endpoint (not a real crossing)
|
|
const samePoint = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
|
Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5;
|
|
|
|
if (samePoint(p1, p3) || samePoint(p1, p4) || samePoint(p2, p3) || samePoint(p2, p4)) {
|
|
return false;
|
|
}
|
|
|
|
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
|
|
}
|
|
|
|
interface Cluster {
|
|
centerX: number;
|
|
centerY: number;
|
|
radius: number;
|
|
nodeIndices: number[];
|
|
}
|
|
|
|
// Find clusters of nearby nodes using distance threshold
|
|
function findClusters(nodes: Array<{ x: number; y: number }>, threshold: number): Cluster[] {
|
|
const visited = new Set<number>();
|
|
const clusters: Cluster[] = [];
|
|
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
if (visited.has(i)) continue;
|
|
|
|
// BFS to find all connected nodes
|
|
const clusterIndices: number[] = [];
|
|
const queue = [i];
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!;
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
clusterIndices.push(current);
|
|
|
|
// Find neighbors within threshold
|
|
for (let j = 0; j < nodes.length; j++) {
|
|
if (visited.has(j)) continue;
|
|
const dist = Math.hypot(nodes[j].x - nodes[current].x, nodes[j].y - nodes[current].y);
|
|
if (dist < threshold) {
|
|
queue.push(j);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate cluster center and radius
|
|
if (clusterIndices.length > 0) {
|
|
const clusterNodes = clusterIndices.map((idx) => nodes[idx]);
|
|
const centerX = clusterNodes.reduce((sum, n) => sum + n.x, 0) / clusterNodes.length;
|
|
const centerY = clusterNodes.reduce((sum, n) => sum + n.y, 0) / clusterNodes.length;
|
|
|
|
// Radius is max distance from center + padding for labels
|
|
let maxDist = 0;
|
|
for (const n of clusterNodes) {
|
|
const dist = Math.hypot(n.x - centerX, n.y - centerY);
|
|
maxDist = Math.max(maxDist, dist);
|
|
}
|
|
const radius = maxDist + 15; // Padding for labels
|
|
|
|
clusters.push({ centerX, centerY, radius, nodeIndices: clusterIndices });
|
|
}
|
|
}
|
|
|
|
return clusters;
|
|
}
|
|
|
|
// Generate candidate positions for a node based on cluster membership
|
|
function generateCandidatePositions(
|
|
node: { x: number; y: number },
|
|
cluster: Cluster | null
|
|
): Array<{ x: number; y: number }> {
|
|
const positions: Array<{ x: number; y: number }> = [];
|
|
|
|
if (cluster && cluster.nodeIndices.length > 1) {
|
|
// For clustered nodes: project outward from cluster center through the node
|
|
const baseAngle = Math.atan2(node.y - cluster.centerY, node.x - cluster.centerX);
|
|
|
|
// Primary position: on the cluster circle boundary
|
|
// Plus variations around that angle for fine-tuning
|
|
const angleVariations = [0, -0.25, 0.25, -0.5, 0.5, -0.75, 0.75];
|
|
const distanceVariations = [cluster.radius, cluster.radius + 6, cluster.radius - 4];
|
|
|
|
for (const distVar of distanceVariations) {
|
|
for (const angleVar of angleVariations) {
|
|
const angle = baseAngle + angleVar;
|
|
const x = cluster.centerX + Math.cos(angle) * distVar;
|
|
const y = cluster.centerY + Math.sin(angle) * distVar * 0.85;
|
|
positions.push({ x, y });
|
|
}
|
|
}
|
|
} else {
|
|
// For isolated nodes: use clock positions around the node
|
|
const distances = [12, 16];
|
|
for (const dist of distances) {
|
|
for (let hour = 0; hour < 12; hour++) {
|
|
const angle = (hour * 30 - 90) * (Math.PI / 180);
|
|
const dx = Math.cos(angle) * dist;
|
|
const dy = Math.sin(angle) * dist * 0.75;
|
|
positions.push({ x: node.x + dx, y: node.y + dy });
|
|
}
|
|
}
|
|
}
|
|
|
|
return positions;
|
|
}
|
|
|
|
// Calculate optimal label positions to avoid overlaps
|
|
function calculateLabelPositions(items: OpportunityItem[]): LabelPosition[] {
|
|
if (items.length === 0) return [];
|
|
|
|
// Convert items to node positions
|
|
const nodes = items.map((item) => ({
|
|
x: 15 + item.x * 70,
|
|
y: 30 + item.y * 55,
|
|
subcode: item.subcode,
|
|
}));
|
|
|
|
// Find clusters of nearby nodes
|
|
const clusters = findClusters(nodes, 15);
|
|
|
|
// Map each node to its cluster (if any)
|
|
const nodeToCluster = new Map<number, Cluster>();
|
|
for (const cluster of clusters) {
|
|
if (cluster.nodeIndices.length > 1) {
|
|
for (const idx of cluster.nodeIndices) {
|
|
nodeToCluster.set(idx, cluster);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize result array with default positions
|
|
const result: LabelPosition[] = nodes.map((node) => ({
|
|
x: node.x + 12,
|
|
y: node.y,
|
|
connector: 'left' as ConnectorSide,
|
|
nodeX: node.x,
|
|
nodeY: node.y,
|
|
}));
|
|
|
|
// Track which labels have been placed
|
|
const placedLabels: LabelPosition[] = [];
|
|
|
|
// Sort: process clustered nodes first (by cluster size desc), then by angle within cluster
|
|
const sortedIndices = nodes
|
|
.map((_, i) => i)
|
|
.sort((a, b) => {
|
|
const clusterA = nodeToCluster.get(a);
|
|
const clusterB = nodeToCluster.get(b);
|
|
const sizeA = clusterA?.nodeIndices.length ?? 0;
|
|
const sizeB = clusterB?.nodeIndices.length ?? 0;
|
|
|
|
if (sizeA !== sizeB) return sizeB - sizeA; // Larger clusters first
|
|
|
|
// Within same cluster, sort by angle from center
|
|
if (clusterA && clusterA === clusterB) {
|
|
const angleA = Math.atan2(nodes[a].y - clusterA.centerY, nodes[a].x - clusterA.centerX);
|
|
const angleB = Math.atan2(nodes[b].y - clusterA.centerY, nodes[b].x - clusterA.centerX);
|
|
return angleA - angleB;
|
|
}
|
|
|
|
return nodes[a].y - nodes[b].y;
|
|
});
|
|
|
|
for (const idx of sortedIndices) {
|
|
const node = nodes[idx];
|
|
const cluster = nodeToCluster.get(idx) ?? null;
|
|
const candidates = generateCandidatePositions(node, cluster);
|
|
|
|
let bestPosition: LabelPosition | null = null;
|
|
let minScore = Infinity;
|
|
|
|
for (const candidate of candidates) {
|
|
// Determine which connector side based on angle from node to label center
|
|
const connector = getConnectorSide(node.x, node.y, candidate.x, candidate.y);
|
|
|
|
const pos: LabelPosition = {
|
|
x: candidate.x,
|
|
y: candidate.y,
|
|
connector,
|
|
nodeX: node.x,
|
|
nodeY: node.y,
|
|
};
|
|
|
|
// Check bounds (keep label within quadrant)
|
|
const rect = getLabelRect(pos);
|
|
if (rect.x < 3 || rect.x + rect.w > 97 || rect.y < 22 || rect.y + rect.h > 93) {
|
|
continue;
|
|
}
|
|
|
|
// Calculate score (lower is better)
|
|
let score = 0;
|
|
|
|
// Penalty for overlapping with placed labels
|
|
for (const placed of placedLabels) {
|
|
const placedRect = getLabelRect(placed);
|
|
if (rectsOverlap(rect, placedRect, 2)) {
|
|
const overlapX = Math.max(0, Math.min(rect.x + rect.w, placedRect.x + placedRect.w) - Math.max(rect.x, placedRect.x));
|
|
const overlapY = Math.max(0, Math.min(rect.y + rect.h, placedRect.y + placedRect.h) - Math.max(rect.y, placedRect.y));
|
|
score += (overlapX * overlapY) * 10;
|
|
}
|
|
}
|
|
|
|
// Penalty for overlapping with other nodes (dots)
|
|
for (const otherNode of nodes) {
|
|
if (otherNode === node) continue;
|
|
const nodeRect = { x: otherNode.x - 3, y: otherNode.y - 3, w: 6, h: 6 };
|
|
if (rectsOverlap(rect, nodeRect, 0)) {
|
|
score += 100;
|
|
}
|
|
}
|
|
|
|
// Heavy penalty for leader line crossing other leader lines (using connector points)
|
|
const connectorPoint = getConnectorPoint(pos);
|
|
for (const placed of placedLabels) {
|
|
const placedConnector = getConnectorPoint(placed);
|
|
if (linesIntersect(
|
|
{ x: node.x, y: node.y },
|
|
connectorPoint,
|
|
{ x: placed.nodeX, y: placed.nodeY },
|
|
placedConnector
|
|
)) {
|
|
score += 200;
|
|
}
|
|
}
|
|
|
|
// Small penalty for distance from node (prefer closer positions)
|
|
const dist = Math.hypot(candidate.x - node.x, candidate.y - node.y);
|
|
score += dist * 0.05;
|
|
|
|
if (score < minScore) {
|
|
minScore = score;
|
|
bestPosition = pos;
|
|
}
|
|
|
|
if (score < 0.1) break;
|
|
}
|
|
|
|
if (bestPosition) {
|
|
result[idx] = bestPosition;
|
|
}
|
|
|
|
placedLabels.push(result[idx]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function Quadrant({
|
|
title,
|
|
icon,
|
|
items,
|
|
bgColor,
|
|
borderColor,
|
|
textColor,
|
|
iconBg,
|
|
onItemClick,
|
|
onItemHover,
|
|
selectedItem,
|
|
}: QuadrantProps) {
|
|
// Calculate optimized label positions
|
|
const labelPositions = useMemo(() => calculateLabelPositions(items), [items]);
|
|
|
|
return (
|
|
<div className={`${bgColor} rounded-lg border ${borderColor} 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 leader lines */}
|
|
{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) => {
|
|
const pos = labelPositions[index];
|
|
if (!pos) return null;
|
|
|
|
// Get the connector point on the label edge
|
|
const connector = getConnectorPoint(pos);
|
|
const isSelected = selectedItem?.subcode === item.subcode;
|
|
|
|
return (
|
|
<g key={`${item.subcode}-${index}`}>
|
|
{/* Leader line from node to label connector */}
|
|
<line
|
|
x1={`${pos.nodeX}%`}
|
|
y1={`${pos.nodeY}%`}
|
|
x2={`${connector.x}%`}
|
|
y2={`${connector.y}%`}
|
|
stroke="currentColor"
|
|
strokeWidth={isSelected ? 2 : 1}
|
|
opacity={isSelected ? 0.6 : 0.3}
|
|
/>
|
|
{/* Dot at node position */}
|
|
<circle
|
|
cx={`${pos.nodeX}%`}
|
|
cy={`${pos.nodeY}%`}
|
|
r={isSelected ? 5 : 4}
|
|
fill="currentColor"
|
|
opacity={isSelected ? 0.8 : 0.6}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
)}
|
|
|
|
{/* Clickable labels */}
|
|
{items.length > 0 && items.map((item, index) => {
|
|
const pos = labelPositions[index];
|
|
if (!pos) return null;
|
|
|
|
// Truncate long names for display
|
|
const displayName = item.name.length > 14 ? item.name.slice(0, 12) + '…' : item.name;
|
|
const isSelected = selectedItem?.subcode === item.subcode;
|
|
|
|
return (
|
|
<button
|
|
key={`label-${item.subcode}-${index}`}
|
|
onClick={() => onItemClick?.(item)}
|
|
onMouseEnter={() => onItemHover?.(item)}
|
|
onMouseLeave={() => onItemHover?.(null)}
|
|
className={`absolute px-1.5 py-0.5 text-[10px] font-medium rounded ${textColor} transition-all shadow-sm border z-20 whitespace-nowrap ${
|
|
isSelected
|
|
? 'bg-white scale-110 ring-2 ring-current/40 border-current/40'
|
|
: 'bg-white/95 hover:bg-white hover:scale-105 border-current/20'
|
|
}`}
|
|
style={{
|
|
left: `${pos.x}%`,
|
|
top: `${pos.y}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
>
|
|
{displayName}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Hover preview card
|
|
function HoverCard({ item, position }: { item: OpportunityItem; position: { x: number; y: number } }) {
|
|
return (
|
|
<div
|
|
className="fixed z-50 bg-white rounded-lg shadow-xl border border-gray-200 p-3 w-56 pointer-events-none"
|
|
style={{
|
|
left: position.x + 10,
|
|
top: position.y - 10,
|
|
transform: 'translateY(-100%)',
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2 mb-2">
|
|
<div
|
|
className="w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
|
style={{ backgroundColor: DOMAIN_COLORS[item.domain] || '#6b7280' }}
|
|
/>
|
|
<div>
|
|
<div className="font-semibold text-sm text-gray-900">{item.name}</div>
|
|
<div className="text-xs text-gray-500">{item.domain_name}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-gray-500">Negative</span>
|
|
<span className="font-medium text-red-600">{item.negative_pct}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
|
<div
|
|
className="bg-red-500 h-1.5 rounded-full"
|
|
style={{ width: `${item.negative_pct}%` }}
|
|
/>
|
|
</div>
|
|
{item.rating_impact && (
|
|
<div className="flex justify-between text-xs pt-1">
|
|
<span className="text-gray-500">Rating impact</span>
|
|
<span className="font-medium text-green-600">+{item.rating_impact}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-2 pt-2 border-t border-gray-100 text-xs text-gray-400">
|
|
Click for details →
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render stars for rating
|
|
function RatingStars({ rating }: { rating: number }) {
|
|
return (
|
|
<div className="flex items-center gap-0.5">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<Star
|
|
key={star}
|
|
className={`w-3 h-3 ${
|
|
star <= rating
|
|
? 'text-amber-400 fill-amber-400'
|
|
: 'text-gray-300 fill-gray-300'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Normalize text for matching (handles spacing differences around punctuation)
|
|
function normalizeForMatching(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/\s+([.,!?;:])/g, '$1') // Remove space before punctuation
|
|
.replace(/([.,!?;:])\s+/g, '$1 ') // Normalize space after punctuation
|
|
.replace(/\s+/g, ' ') // Multiple spaces to single
|
|
.trim();
|
|
}
|
|
|
|
// Find span in review using normalized matching, return original indices
|
|
function findSpanInReview(reviewText: string, spanText: string): { start: number; end: number } | null {
|
|
// Try exact match first (case-insensitive)
|
|
const lowerReview = reviewText.toLowerCase();
|
|
const lowerSpan = spanText.toLowerCase();
|
|
let idx = lowerReview.indexOf(lowerSpan);
|
|
if (idx !== -1) {
|
|
return { start: idx, end: idx + spanText.length };
|
|
}
|
|
|
|
// Try normalized matching
|
|
const normReview = normalizeForMatching(reviewText);
|
|
const normSpan = normalizeForMatching(spanText);
|
|
const normIdx = normReview.indexOf(normSpan);
|
|
|
|
if (normIdx === -1) return null;
|
|
|
|
// Map normalized index back to original text
|
|
// Walk through original text, tracking normalized position
|
|
let origIdx = 0;
|
|
let normPos = 0;
|
|
const targetNormStart = normIdx;
|
|
const targetNormEnd = normIdx + normSpan.length;
|
|
let origStart = -1;
|
|
let origEnd = -1;
|
|
|
|
while (origIdx <= reviewText.length && normPos <= normReview.length) {
|
|
if (normPos === targetNormStart) origStart = origIdx;
|
|
if (normPos === targetNormEnd) {
|
|
origEnd = origIdx;
|
|
break;
|
|
}
|
|
|
|
if (origIdx < reviewText.length) {
|
|
const origChar = reviewText[origIdx];
|
|
const normChar = normReview[normPos];
|
|
|
|
// Skip characters that were removed in normalization
|
|
if (origChar.toLowerCase() === normChar) {
|
|
origIdx++;
|
|
normPos++;
|
|
} else if (/\s/.test(origChar)) {
|
|
origIdx++; // Skip extra whitespace in original
|
|
} else {
|
|
origIdx++;
|
|
normPos++;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (origStart !== -1 && origEnd === -1) {
|
|
origEnd = reviewText.length;
|
|
}
|
|
|
|
return origStart !== -1 ? { start: origStart, end: origEnd } : null;
|
|
}
|
|
|
|
// Highlight span text within review text
|
|
function HighlightedReviewText({
|
|
reviewText,
|
|
spanText,
|
|
maxLength = 150,
|
|
expanded = false,
|
|
}: {
|
|
reviewText: string;
|
|
spanText: string;
|
|
maxLength?: number;
|
|
expanded?: boolean;
|
|
}) {
|
|
// Find the span within the review using fuzzy matching
|
|
const match = findSpanInReview(reviewText, spanText);
|
|
|
|
if (!match) {
|
|
// Span not found, just show truncated review
|
|
const displayText = expanded ? reviewText : reviewText.slice(0, maxLength);
|
|
return (
|
|
<span>
|
|
{displayText}
|
|
{!expanded && reviewText.length > maxLength && '...'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const spanIndex = match.start;
|
|
const spanEnd = match.end;
|
|
|
|
// If span covers most of the review (>80%), highlight the whole thing
|
|
const spanCoverage = (spanEnd - spanIndex) / reviewText.length;
|
|
if (spanCoverage > 0.8) {
|
|
const displayText = expanded ? reviewText : reviewText.slice(0, maxLength);
|
|
return (
|
|
<mark className="bg-yellow-200 text-gray-900 px-0.5 rounded">
|
|
{displayText}
|
|
{!expanded && reviewText.length > maxLength && '...'}
|
|
</mark>
|
|
);
|
|
}
|
|
|
|
// Calculate display window around the span
|
|
let displayStart = 0;
|
|
let displayEnd = reviewText.length;
|
|
|
|
if (!expanded) {
|
|
// Show context around the span
|
|
const contextBefore = 40;
|
|
const contextAfter = 60;
|
|
displayStart = Math.max(0, spanIndex - contextBefore);
|
|
displayEnd = Math.min(reviewText.length, spanEnd + contextAfter);
|
|
|
|
// Adjust to not cut words
|
|
if (displayStart > 0) {
|
|
const spaceIndex = reviewText.indexOf(' ', displayStart);
|
|
if (spaceIndex !== -1 && spaceIndex < spanIndex) {
|
|
displayStart = spaceIndex + 1;
|
|
}
|
|
}
|
|
if (displayEnd < reviewText.length) {
|
|
const spaceIndex = reviewText.lastIndexOf(' ', displayEnd);
|
|
if (spaceIndex !== -1 && spaceIndex > spanEnd) {
|
|
displayEnd = spaceIndex;
|
|
}
|
|
}
|
|
}
|
|
|
|
const before = reviewText.slice(displayStart, spanIndex);
|
|
const highlight = reviewText.slice(spanIndex, spanEnd);
|
|
const after = reviewText.slice(spanEnd, displayEnd);
|
|
|
|
return (
|
|
<span>
|
|
{displayStart > 0 && '...'}
|
|
{before}
|
|
<mark className="bg-yellow-200 text-gray-900 px-0.5 rounded">{highlight}</mark>
|
|
{after}
|
|
{!expanded && displayEnd < reviewText.length && '...'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Google-style review card for customer feedback
|
|
function SpanCard({
|
|
span,
|
|
onViewReview
|
|
}: {
|
|
span: OpportunitySpan;
|
|
onViewReview?: (reviewId: string, spanId: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const reviewText = span.review_text || span.span_text;
|
|
const isLongReview = reviewText.length > 150;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg p-3 border border-gray-200 hover:shadow-sm transition-shadow group">
|
|
{/* Header: Stars + Date + Expand button */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{span.rating && <RatingStars rating={span.rating} />}
|
|
{span.review_date && (
|
|
<span className="text-xs text-gray-500">{span.review_date}</span>
|
|
)}
|
|
{span.review_id && onViewReview && (
|
|
<button
|
|
onClick={() => onViewReview(span.review_id!, span.span_id)}
|
|
className="ml-auto p-1 rounded hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="View full review"
|
|
>
|
|
<Maximize2 className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Review text with highlighted span */}
|
|
<p className="text-sm text-gray-700 leading-relaxed">
|
|
{span.review_text ? (
|
|
<HighlightedReviewText
|
|
reviewText={span.review_text}
|
|
spanText={span.span_text}
|
|
expanded={expanded}
|
|
/>
|
|
) : (
|
|
<span>"{span.span_text}"</span>
|
|
)}
|
|
{isLongReview && !expanded && (
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
className="ml-1 text-blue-600 hover:text-blue-800 font-medium"
|
|
>
|
|
More
|
|
</button>
|
|
)}
|
|
{expanded && isLongReview && (
|
|
<button
|
|
onClick={() => setExpanded(false)}
|
|
className="ml-1 text-blue-600 hover:text-blue-800 font-medium"
|
|
>
|
|
Less
|
|
</button>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Detail panel component
|
|
function DetailPanel({
|
|
item,
|
|
onClose,
|
|
onViewReview,
|
|
}: {
|
|
item: OpportunityItem;
|
|
onClose: () => void;
|
|
onViewReview?: (reviewId: string, spanId: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl border-2 border-gray-200 shadow-md p-4 h-full flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-4 flex-shrink-0">
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
|
|
style={{ backgroundColor: DOMAIN_COLORS[item.domain] || '#6b7280' }}
|
|
/>
|
|
<div>
|
|
<h3 className="font-bold text-gray-900">{item.name}</h3>
|
|
<div className="text-sm text-gray-500">{item.domain_name} · {item.subcode}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
>
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Metrics */}
|
|
<div className="grid grid-cols-2 gap-3 mb-4 flex-shrink-0">
|
|
<div className="bg-red-50 rounded-lg p-3">
|
|
<div className="text-xs text-red-600 mb-1">Negative Sentiment</div>
|
|
<div className="text-2xl font-bold text-red-700">{item.negative_pct}%</div>
|
|
<div className="text-xs text-red-500">{item.span_count} mentions</div>
|
|
</div>
|
|
{item.rating_impact && (
|
|
<div className="bg-green-50 rounded-lg p-3">
|
|
<div className="text-xs text-green-600 mb-1 flex items-center gap-1">
|
|
<TrendingUp className="w-3 h-3" />
|
|
If Fixed
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-700">+{item.rating_impact}</div>
|
|
<div className="text-xs text-green-500">rating points</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Complexity */}
|
|
<div className="flex items-center gap-2 mb-4 p-2 bg-gray-50 rounded-lg flex-shrink-0">
|
|
<Wrench className="w-4 h-4 text-gray-400" />
|
|
<span className="text-sm text-gray-600">Complexity:</span>
|
|
<span className={`text-sm font-medium px-2 py-0.5 rounded ${
|
|
item.complexity === 'simple' ? 'bg-green-100 text-green-700' :
|
|
item.complexity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
{COMPLEXITY_LABELS[item.complexity] || item.complexity}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Customer Feedback Spans (evidence of the problem) - expands to fill available space */}
|
|
{item.spans && item.spans.length > 0 && (
|
|
<div className="mb-4 flex-1 min-h-0 flex flex-col">
|
|
<div className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide flex items-center gap-1.5 flex-shrink-0">
|
|
<MessageSquare className="w-3 h-3" />
|
|
Customer Feedback ({item.spans.length})
|
|
</div>
|
|
<div className="overflow-y-auto space-y-2 pr-1 flex-1 min-h-[100px]">
|
|
{item.spans.map((span) => (
|
|
<SpanCard
|
|
key={span.span_id}
|
|
span={span}
|
|
onViewReview={onViewReview}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Solution */}
|
|
{item.solution && (
|
|
<div className="mb-4 flex-shrink-0">
|
|
<div className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
|
|
💡 Suggested Solution
|
|
</div>
|
|
<p className="text-sm text-gray-700 leading-relaxed bg-blue-50 p-3 rounded-lg border border-blue-100">
|
|
{item.solution}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Owner */}
|
|
{item.owner && (
|
|
<div className="flex items-center gap-2 p-2 bg-purple-50 rounded-lg mb-4 flex-shrink-0">
|
|
<Users className="w-4 h-4 text-purple-500" />
|
|
<span className="text-sm text-purple-700">
|
|
<span className="text-purple-500">Assign to:</span> {item.owner}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* CTA */}
|
|
<button className="w-full flex items-center justify-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white py-2 px-4 rounded-lg transition-colors text-sm font-medium flex-shrink-0">
|
|
View Related Reviews
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 2x2 Opportunity Matrix visualization.
|
|
* Shows issues categorized by frequency vs complexity with coordinate positioning.
|
|
*/
|
|
export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixProps) {
|
|
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
|
|
const [hoveredItem, setHoveredItem] = useState<OpportunityItem | null>(null);
|
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
|
const [gridSize, setGridSize] = useState<number | null>(null);
|
|
const matrixRef = useRef<HTMLDivElement>(null);
|
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
|
const componentRef = useRef<HTMLDivElement>(null);
|
|
const [reviewModal, setReviewModal] = useState<{ reviewId: string; spanId: string } | null>(null);
|
|
|
|
// Calculate grid size based on available space
|
|
useEffect(() => {
|
|
const updateGridSize = () => {
|
|
// Calculate max available height for the grid (viewport - margins - header - labels - legend)
|
|
const maxAvailableHeight = window.innerHeight - 300;
|
|
|
|
// Get component width, then subtract detail panel min (280px) + y-axis label (40px) + gaps (32px)
|
|
const componentWidth = componentRef.current?.offsetWidth ?? window.innerWidth * 0.6;
|
|
const maxAvailableWidth = componentWidth - 280 - 40 - 32; // detail panel min + y-label + padding/gaps
|
|
|
|
// Grid must be square - use the smaller of available height or width
|
|
const size = Math.min(maxAvailableHeight, maxAvailableWidth);
|
|
setGridSize(Math.max(250, size));
|
|
};
|
|
|
|
// Delay initial calculation to allow layout to settle
|
|
const timeoutId = setTimeout(updateGridSize, 50);
|
|
window.addEventListener('resize', updateGridSize);
|
|
|
|
// Also observe component width changes
|
|
if (componentRef.current) {
|
|
const resizeObserver = new ResizeObserver(updateGridSize);
|
|
resizeObserver.observe(componentRef.current);
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
window.removeEventListener('resize', updateGridSize);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
window.removeEventListener('resize', updateGridSize);
|
|
};
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
|
|
const handleItemClick = (item: OpportunityItem) => {
|
|
setSelectedItem(item);
|
|
onSubcodeClick?.(item.subcode);
|
|
};
|
|
|
|
const handleItemHover = (item: OpportunityItem | null) => {
|
|
setHoveredItem(item);
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
setMousePos({ x: e.clientX, y: e.clientY });
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={componentRef}
|
|
className="bg-white rounded-xl p-4 shadow-md border-2 border-gray-200"
|
|
style={{
|
|
maxHeight: 'calc(100vh - 120px)',
|
|
}}
|
|
onMouseMove={handleMouseMove}
|
|
>
|
|
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
|
|
<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>
|
|
|
|
{/* Main layout: Matrix + Detail Panel */}
|
|
<div className="flex gap-4 items-start">
|
|
{/* Matrix section */}
|
|
<div ref={matrixRef} className="flex-shrink-0">
|
|
{/* Matrix with L-shaped label area */}
|
|
<div className="flex">
|
|
{/* Y-axis label column - height matches grid */}
|
|
<div
|
|
className="flex items-center justify-center w-8 mr-2"
|
|
style={{ height: gridSize ?? 300 }}
|
|
>
|
|
<span className="-rotate-90 text-sm text-gray-600 font-semibold whitespace-nowrap">
|
|
Effort →
|
|
</span>
|
|
</div>
|
|
|
|
{/* Main matrix area */}
|
|
<div ref={gridContainerRef} className="flex flex-col">
|
|
{/* Matrix with arrows */}
|
|
<div className="relative">
|
|
{/* Coordinate axes - L-shaped from bottom-left origin */}
|
|
<svg
|
|
className="absolute pointer-events-none overflow-visible"
|
|
style={{
|
|
zIndex: 5,
|
|
width: gridSize ?? 300,
|
|
height: gridSize ?? 300,
|
|
left: 12,
|
|
top: 0,
|
|
}}
|
|
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"
|
|
style={{
|
|
width: gridSize ?? 300,
|
|
height: gridSize ?? 300,
|
|
maxWidth: 'calc(100% - 12px)',
|
|
}}
|
|
>
|
|
{/* Unified coordinate grid overlay */}
|
|
<svg
|
|
className="absolute top-0 left-0 pointer-events-none"
|
|
style={{ zIndex: 2, width: '100%', height: '100%' }}
|
|
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 grid-rows-2 gap-2 w-full h-full">
|
|
{/* 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={handleItemClick}
|
|
onItemHover={handleItemHover}
|
|
selectedItem={selectedItem}
|
|
/>
|
|
<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={handleItemClick}
|
|
onItemHover={handleItemHover}
|
|
selectedItem={selectedItem}
|
|
/>
|
|
|
|
{/* 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={handleItemClick}
|
|
onItemHover={handleItemHover}
|
|
selectedItem={selectedItem}
|
|
/>
|
|
<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={handleItemClick}
|
|
onItemHover={handleItemHover}
|
|
selectedItem={selectedItem}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* X-axis label row */}
|
|
<div className="flex justify-center mt-4 flex-shrink-0">
|
|
<span className="text-sm text-gray-600 font-semibold">
|
|
Frequency →
|
|
</span>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap gap-3 text-xs text-gray-500 flex-shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-3 h-3 bg-green-200 rounded" />
|
|
<span>Quick Wins</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-3 h-3 bg-red-200 rounded" />
|
|
<span>Critical</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-3 h-3 bg-purple-200 rounded" />
|
|
<span>Strategic</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-3 h-3 bg-gray-200 rounded" />
|
|
<span>Nice to Have</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail Panel - expands horizontally, height matches grid + labels */}
|
|
<div
|
|
className="flex-1 min-w-[280px] overflow-hidden"
|
|
style={{ height: gridSize ? gridSize + 80 : 'auto' }}
|
|
>
|
|
{selectedItem ? (
|
|
<DetailPanel
|
|
item={selectedItem}
|
|
onClose={() => setSelectedItem(null)}
|
|
onViewReview={(reviewId, spanId) => setReviewModal({ reviewId, spanId })}
|
|
/>
|
|
) : (
|
|
<div className="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-6 h-full flex flex-col items-center justify-center text-center">
|
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mb-3">
|
|
<Target className="w-6 h-6 text-gray-400" />
|
|
</div>
|
|
<h4 className="font-medium text-gray-600 mb-1">Select an opportunity</h4>
|
|
<p className="text-sm text-gray-400 max-w-[200px]">
|
|
Click on any item in the matrix to see details and suggested actions
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hover Card */}
|
|
{hoveredItem && !selectedItem && (
|
|
<HoverCard item={hoveredItem} position={mousePos} />
|
|
)}
|
|
|
|
{/* Review Modal */}
|
|
{reviewModal && (
|
|
<ReviewModal
|
|
reviewId={reviewModal.reviewId}
|
|
highlightSpanId={reviewModal.spanId}
|
|
onClose={() => setReviewModal(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|