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:
325
web/components/reviewiq/insights/OpportunityMatrix.tsx
Normal file
325
web/components/reviewiq/insights/OpportunityMatrix.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
web/components/reviewiq/types.ts
Normal file
366
web/components/reviewiq/types.ts
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user