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>
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
getPaginationRowModel,
|
|
ColumnDef,
|
|
flexRender,
|
|
SortingState,
|
|
} from '@tanstack/react-table';
|
|
import { ArrowUpDown, ArrowUp, ArrowDown, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import type { IssueItem, PaginatedIssues } from '../types';
|
|
import { DOMAIN_LABELS, INTENSITY_LABELS } from '../types';
|
|
import { IssueDetailModal } from './IssueDetailModal';
|
|
|
|
interface IssuesTableProps {
|
|
issues: PaginatedIssues;
|
|
onPageChange?: (page: number) => void;
|
|
}
|
|
|
|
// Priority badge color based on score
|
|
const getPriorityColor = (score: number): string => {
|
|
if (score >= 0.8) return 'bg-red-100 text-red-800 border-red-300';
|
|
if (score >= 0.5) return 'bg-orange-100 text-orange-800 border-orange-300';
|
|
if (score >= 0.3) return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
|
};
|
|
|
|
// State badge color
|
|
const getStateColor = (state: string): string => {
|
|
switch (state) {
|
|
case 'open':
|
|
return 'bg-red-100 text-red-800 border-red-300';
|
|
case 'in_progress':
|
|
return 'bg-blue-100 text-blue-800 border-blue-300';
|
|
case 'resolved':
|
|
return 'bg-green-100 text-green-800 border-green-300';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Issues table with TanStack Table.
|
|
* Click rows to open drill-down modal.
|
|
*/
|
|
export function IssuesTable({ issues, onPageChange }: IssuesTableProps) {
|
|
const [sorting, setSorting] = useState<SortingState>([
|
|
{ id: 'priority_score', desc: true },
|
|
]);
|
|
const [selectedIssue, setSelectedIssue] = useState<IssueItem | null>(null);
|
|
|
|
const columns = useMemo<ColumnDef<IssueItem>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: 'primary_subcode',
|
|
header: ({ column }) => (
|
|
<button
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
|
>
|
|
Issue
|
|
{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 }) => (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-sm font-semibold text-gray-900">
|
|
{row.original.subcode_name || row.original.primary_subcode}
|
|
</span>
|
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-800 text-xs font-mono font-bold rounded border border-purple-300 w-fit">
|
|
{row.original.primary_subcode}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'domain',
|
|
header: 'Domain',
|
|
cell: ({ row }) => (
|
|
<span className="text-sm font-medium text-gray-700">
|
|
{DOMAIN_LABELS[row.original.domain] || row.original.domain}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'default_owner',
|
|
header: 'Owner',
|
|
cell: ({ row }) => (
|
|
<span className="text-xs font-medium px-2 py-1 bg-blue-50 text-blue-700 rounded-full border border-blue-200">
|
|
{row.original.default_owner || 'Unassigned'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'state',
|
|
header: 'State',
|
|
cell: ({ row }) => (
|
|
<span
|
|
className={`px-2 py-1 text-xs font-bold rounded border ${getStateColor(row.original.state)}`}
|
|
>
|
|
{row.original.state.toUpperCase()}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'priority_score',
|
|
header: ({ column }) => (
|
|
<button
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
|
>
|
|
Priority
|
|
{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 text-xs font-bold rounded border ${getPriorityColor(row.original.priority_score)}`}
|
|
>
|
|
{(row.original.priority_score * 100).toFixed(0)}%
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'span_count',
|
|
header: 'Spans',
|
|
cell: ({ row }) => (
|
|
<span className="text-sm font-medium text-gray-700">
|
|
{row.original.span_count}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'max_intensity',
|
|
header: 'Intensity',
|
|
cell: ({ row }) => (
|
|
<span className="text-sm text-gray-600">
|
|
{row.original.max_intensity
|
|
? INTENSITY_LABELS[row.original.max_intensity] || row.original.max_intensity
|
|
: '-'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: '',
|
|
cell: ({ row }) => (
|
|
<button
|
|
onClick={() => setSelectedIssue(row.original)}
|
|
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
title="View details"
|
|
>
|
|
<ExternalLink className="w-4 h-4 text-gray-500" />
|
|
</button>
|
|
),
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: issues.items,
|
|
columns,
|
|
state: { sorting },
|
|
onSortingChange: setSorting,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
initialState: {
|
|
pagination: { pageSize: 10 },
|
|
},
|
|
});
|
|
|
|
const totalPages = Math.ceil(issues.total / issues.page_size);
|
|
|
|
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">Issues</h3>
|
|
<p className="text-sm text-gray-500">
|
|
{issues.total} total issues - Click row for details
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{issues.items.length === 0 ? (
|
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
|
No issues 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) => (
|
|
<tr
|
|
key={row.id}
|
|
className="hover:bg-blue-50 cursor-pointer transition-colors"
|
|
onClick={() => setSelectedIssue(row.original)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td key={cell.id} className="px-4 py-3">
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext()
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-gray-700 font-medium">
|
|
Page {issues.page} of {totalPages} ({issues.total} issues)
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => onPageChange?.(issues.page - 1)}
|
|
disabled={issues.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?.(issues.page + 1)}
|
|
disabled={issues.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>
|
|
</>
|
|
)}
|
|
|
|
{/* Detail Modal */}
|
|
{selectedIssue && (
|
|
<IssueDetailModal
|
|
issue={selectedIssue}
|
|
onClose={() => setSelectedIssue(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|