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>
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { X, AlertTriangle, Layers, Calendar, Target, User, Lightbulb, FileText } from 'lucide-react';
|
|
import type { IssueItem, SpanItem } from '../types';
|
|
import { DOMAIN_LABELS, INTENSITY_LABELS, VALENCE_LABELS, VALENCE_COLORS } from '../types';
|
|
import { useIssueSpans } from '@/hooks/useReviewIQAnalytics';
|
|
import { ReviewModal } from './ReviewModal';
|
|
|
|
interface IssueDetailModalProps {
|
|
issue: IssueItem;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* Modal showing issue details and related spans.
|
|
*/
|
|
export function IssueDetailModal({ issue, onClose }: IssueDetailModalProps) {
|
|
const { data: spans, loading, error } = useIssueSpans(issue.issue_id);
|
|
const [selectedReview, setSelectedReview] = useState<{
|
|
reviewId: string;
|
|
spanId: string;
|
|
} | null>(null);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-white border-b-2 border-gray-200 px-6 py-4 rounded-t-2xl">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900">
|
|
{issue.subcode_name || issue.primary_subcode}
|
|
</h3>
|
|
<span className="text-sm font-mono text-gray-500">{issue.primary_subcode}</span>
|
|
</div>
|
|
<span
|
|
className={`px-2 py-1 text-xs font-bold rounded border ${
|
|
issue.state === 'open'
|
|
? 'bg-red-100 text-red-800 border-red-300'
|
|
: issue.state === 'resolved'
|
|
? 'bg-green-100 text-green-800 border-green-300'
|
|
: 'bg-gray-100 text-gray-800 border-gray-300'
|
|
}`}
|
|
>
|
|
{issue.state.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
|
>
|
|
<X className="w-5 h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-6">
|
|
{/* Issue Info Grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Target className="w-4 h-4 text-purple-600" />
|
|
<span className="text-xs font-semibold text-purple-700">URT Code</span>
|
|
</div>
|
|
<span className="text-lg font-mono font-bold text-purple-900">
|
|
{issue.primary_subcode}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Layers className="w-4 h-4 text-blue-600" />
|
|
<span className="text-xs font-semibold text-blue-700">Domain</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-blue-900">
|
|
{DOMAIN_LABELS[issue.domain] || issue.domain}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
|
<span className="text-xs font-semibold text-orange-700">Priority</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-orange-900">
|
|
{(issue.priority_score * 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Calendar className="w-4 h-4 text-gray-600" />
|
|
<span className="text-xs font-semibold text-gray-700">Intensity</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-gray-900">
|
|
{issue.max_intensity
|
|
? INTENSITY_LABELS[issue.max_intensity] || issue.max_intensity
|
|
: 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Entity */}
|
|
{issue.entity && (
|
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
<span className="text-sm font-semibold text-gray-700">Related Entity</span>
|
|
<p className="text-lg font-medium text-gray-900 mt-1">{issue.entity}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Solution & Owner Section */}
|
|
{(issue.solution || issue.default_owner) && (
|
|
<div className="bg-green-50 rounded-lg p-4 border border-green-200 space-y-3">
|
|
{issue.solution && (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Lightbulb className="w-5 h-5 text-green-600" />
|
|
<span className="text-sm font-bold text-green-800">Recommended Solution</span>
|
|
{issue.solution_complexity && (
|
|
<span className={`ml-auto text-xs font-bold px-2 py-1 rounded ${
|
|
issue.solution_complexity === 'low'
|
|
? 'bg-green-100 text-green-700'
|
|
: issue.solution_complexity === 'medium'
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{issue.solution_complexity.charAt(0).toUpperCase() + issue.solution_complexity.slice(1)} Complexity
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-gray-700 leading-relaxed">{issue.solution}</p>
|
|
</div>
|
|
)}
|
|
|
|
{issue.default_owner && (
|
|
<div className="flex items-center gap-2 pt-2 border-t border-green-200">
|
|
<User className="w-4 h-4 text-green-600" />
|
|
<span className="text-sm text-green-800">
|
|
<span className="font-medium">Assign to:</span>{' '}
|
|
<span className="font-bold">{issue.default_owner}</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Related Spans */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h4 className="text-lg font-bold text-gray-900">
|
|
Related Spans ({issue.span_count})
|
|
</h4>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex items-center justify-center h-32 text-red-500">
|
|
Failed to load spans: {error}
|
|
</div>
|
|
) : spans.length === 0 ? (
|
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
|
No spans found for this issue
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|
{spans.map((span: SpanItem) => (
|
|
<div
|
|
key={span.span_id}
|
|
className="bg-gray-50 rounded-lg p-4 border border-gray-200"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<p className="text-gray-800 flex-1">{span.span_text}</p>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{span.valence && (
|
|
<span
|
|
className="px-2 py-1 text-xs font-bold rounded"
|
|
style={{
|
|
backgroundColor: `${VALENCE_COLORS[span.valence]}20`,
|
|
color: VALENCE_COLORS[span.valence],
|
|
}}
|
|
>
|
|
{VALENCE_LABELS[span.valence] || span.valence}
|
|
</span>
|
|
)}
|
|
{span.intensity && (
|
|
<span className="px-2 py-1 bg-gray-200 text-gray-700 text-xs font-bold rounded">
|
|
{INTENSITY_LABELS[span.intensity] || span.intensity}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 mt-2">
|
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
{span.urt_primary && (
|
|
<span className="font-mono">{span.urt_primary}</span>
|
|
)}
|
|
{span.review_time && (
|
|
<span>{new Date(span.review_time).toLocaleDateString()}</span>
|
|
)}
|
|
{span.entity && <span>Entity: {span.entity}</span>}
|
|
</div>
|
|
{/* View Full Review Button */}
|
|
{span.source_review_id && (
|
|
<button
|
|
onClick={() =>
|
|
setSelectedReview({
|
|
reviewId: span.source_review_id!,
|
|
spanId: span.span_id,
|
|
})
|
|
}
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
|
>
|
|
<FileText className="w-3 h-3" />
|
|
View Review
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="sticky bottom-0 bg-white border-t-2 border-gray-200 px-6 py-4 rounded-b-2xl">
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-3 bg-gray-900 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Review Modal for drill-down */}
|
|
{selectedReview && (
|
|
<ReviewModal
|
|
reviewId={selectedReview.reviewId}
|
|
highlightSpanId={selectedReview.spanId}
|
|
onClose={() => setSelectedReview(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|