feat(reviewiq): Add AI synthesis support to dashboard components
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>
This commit is contained in:
334
web/components/reviewiq/tables/SpansTable.tsx
Normal file
334
web/components/reviewiq/tables/SpansTable.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getExpandedRowModel,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
Row,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import type { SpanItem, PaginatedSpans } from '../types';
|
||||
import { VALENCE_LABELS, VALENCE_COLORS, INTENSITY_LABELS, DOMAIN_LABELS } from '../types';
|
||||
import { ReviewModal } from './ReviewModal';
|
||||
|
||||
interface SpansTableProps {
|
||||
spans: PaginatedSpans;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spans table with expandable rows and drill-down to full review.
|
||||
*/
|
||||
export function SpansTable({ spans, onPageChange }: SpansTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [selectedReview, setSelectedReview] = useState<{
|
||||
reviewId: string;
|
||||
spanId: string;
|
||||
} | null>(null);
|
||||
|
||||
const columns = useMemo<ColumnDef<SpanItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'expander',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
onClick={() => row.toggleExpanded()}
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'span_text',
|
||||
header: 'Text',
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-md">
|
||||
<p className="text-sm text-gray-800 line-clamp-2">
|
||||
{row.original.span_text}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'urt_primary',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
URT Code
|
||||
{column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-800 text-xs font-mono font-bold rounded border border-purple-300">
|
||||
{row.original.urt_primary || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'valence',
|
||||
header: 'Sentiment',
|
||||
cell: ({ row }) => {
|
||||
const valence = row.original.valence;
|
||||
if (!valence) return <span className="text-gray-400">-</span>;
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-bold rounded"
|
||||
style={{
|
||||
backgroundColor: `${VALENCE_COLORS[valence]}20`,
|
||||
color: VALENCE_COLORS[valence],
|
||||
}}
|
||||
>
|
||||
{VALENCE_LABELS[valence] || valence}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'intensity',
|
||||
header: 'Intensity',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{row.original.intensity
|
||||
? INTENSITY_LABELS[row.original.intensity] || row.original.intensity
|
||||
: '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'review_time',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Date
|
||||
{column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{row.original.review_time
|
||||
? new Date(row.original.review_time).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: spans.items,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
expanded,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onExpandedChange: setExpanded as any,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowCanExpand: () => true,
|
||||
initialState: {
|
||||
pagination: { pageSize: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(spans.total / spans.page_size);
|
||||
|
||||
// Render expanded row content
|
||||
const renderExpandedRow = (row: Row<SpanItem>) => {
|
||||
const span = row.original;
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="bg-gray-50 p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-700">Full Text</span>
|
||||
<p className="text-sm text-gray-800 mt-1 whitespace-pre-wrap">
|
||||
{span.span_text}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Span ID</span>
|
||||
<p className="text-gray-600 font-mono text-xs">{span.span_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Review ID</span>
|
||||
<p className="text-gray-600 font-mono text-xs">
|
||||
{span.source_review_id || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Entity</span>
|
||||
<p className="text-gray-600">{span.entity || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Domain</span>
|
||||
<p className="text-gray-600">
|
||||
{span.urt_primary
|
||||
? DOMAIN_LABELS[span.urt_primary[0]] || span.urt_primary[0]
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* View Full Review Button */}
|
||||
{span.source_review_id && (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedReview({
|
||||
reviewId: span.source_review_id!,
|
||||
spanId: span.span_id,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
View Full Review
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Classified Spans</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{spans.total} total spans - Click row to expand
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spans.items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||
No spans found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto border-2 border-gray-200 rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b-2 border-gray-200">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-4 py-3 text-left text-gray-900"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<tr
|
||||
className="hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
onClick={() => row.toggleExpanded()}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-4 py-3">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{row.getIsExpanded() && renderExpandedRow(row)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-700 font-medium">
|
||||
Page {spans.page} of {totalPages} ({spans.total} spans)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange?.(spans.page - 1)}
|
||||
disabled={spans.page <= 1}
|
||||
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange?.(spans.page + 1)}
|
||||
disabled={spans.page >= totalPages}
|
||||
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Review Modal for drill-down */}
|
||||
<ReviewModal
|
||||
reviewId={selectedReview?.reviewId ?? null}
|
||||
highlightSpanId={selectedReview?.spanId}
|
||||
onClose={() => setSelectedReview(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user