Updates all display labels (PDF report, campaign page, Knowledge Base card, analytics, status dashboard, checks overview) and aligns internal agent name in backend. Adds migration 010 to update the knowledge base display_name in production DB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
921 lines
No EOL
50 KiB
TypeScript
Executable file
921 lines
No EOL
50 KiB
TypeScript
Executable file
import React, { useState, useEffect } from 'react';
|
|
import type { AgentReview, SubReview, RagStatus, OverallStatus, FlaggedItem, ResolvedItem } from '../types';
|
|
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from './icons/StatusIcons';
|
|
import { FlagIcon } from './icons/FlagIcon';
|
|
import { XIcon } from './icons/XIcon';
|
|
import { BugIcon } from './icons/BugIcon';
|
|
import { ExportIcon } from './icons/ExportIcon';
|
|
import { LegalIcon } from './icons/LegalIcon';
|
|
|
|
/**
|
|
* Formats feedback text from Gemini API into properly structured React elements.
|
|
* Handles HTML tags, bullet characters, and newline-separated text.
|
|
*/
|
|
/**
|
|
* Renders inline markdown bold (**text**) as <strong> elements.
|
|
* Returns an array of React nodes with bold segments wrapped in <strong>.
|
|
*/
|
|
const renderBoldMarkdown = (text: string): React.ReactNode[] => {
|
|
const parts: React.ReactNode[] = [];
|
|
const regex = /\*\*(.+?)\*\*/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.slice(lastIndex, match.index));
|
|
}
|
|
parts.push(<strong key={match.index}>{match[1]}</strong>);
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
|
|
if (lastIndex < text.length) {
|
|
parts.push(text.slice(lastIndex));
|
|
}
|
|
|
|
return parts;
|
|
};
|
|
|
|
const formatFeedbackText = (text: string): React.ReactNode => {
|
|
if (!text) return null;
|
|
|
|
// First, handle HTML tags by converting them to a normalized format
|
|
let normalizedText = text
|
|
// Convert literal \n sequences to real newlines
|
|
.replace(/\\n/g, '\n')
|
|
// Replace </li> and </ul> with newlines
|
|
.replace(/<\/li>/gi, '\n')
|
|
.replace(/<\/ul>/gi, '\n')
|
|
// Remove opening tags
|
|
.replace(/<ul[^>]*>/gi, '')
|
|
.replace(/<li[^>]*>/gi, '• ')
|
|
// Remove any other HTML tags (but preserve ** markdown bold)
|
|
.replace(/<[^>]+>/g, '')
|
|
// Normalize whitespace around bullets
|
|
.replace(/\s*•\s*/g, '\n• ')
|
|
// Clean up multiple newlines
|
|
.replace(/\n{2,}/g, '\n')
|
|
.trim();
|
|
|
|
// Split into lines
|
|
const lines = normalizedText.split('\n').filter(line => line.trim());
|
|
|
|
// Separate intro text from bullet points
|
|
// Each bullet is an array of lines (to support multi-line Issue/Recommendation)
|
|
const bulletGroups: string[][] = [];
|
|
const introLines: string[] = [];
|
|
|
|
lines.forEach(line => {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('•')) {
|
|
// Start a new bullet group
|
|
bulletGroups.push([trimmed.replace(/^•\s*/, '').trim()]);
|
|
} else if (trimmed) {
|
|
if (bulletGroups.length === 0) {
|
|
// Haven't started bullets yet — it's intro text
|
|
introLines.push(trimmed);
|
|
} else {
|
|
// Continuation line within the current bullet
|
|
bulletGroups[bulletGroups.length - 1].push(trimmed);
|
|
}
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{introLines.length > 0 && (
|
|
<p className="mb-3">{renderBoldMarkdown(introLines.join(' '))}</p>
|
|
)}
|
|
{bulletGroups.length > 0 && (
|
|
<ul className="list-disc list-inside">
|
|
{bulletGroups.map((group, index) => (
|
|
<React.Fragment key={index}>
|
|
{group.map((line, lineIdx) => (
|
|
<li key={lineIdx} className={lineIdx === 0 && index > 0 ? 'mt-3' : ''}>
|
|
{renderBoldMarkdown(line)}
|
|
</li>
|
|
))}
|
|
</React.Fragment>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const RagStatusBadge: React.FC<{ status: RagStatus; isLarge?: boolean }> = ({ status, isLarge = false }) => {
|
|
let colorClasses = '';
|
|
let iconColor = '';
|
|
|
|
switch (status) {
|
|
case 'Red':
|
|
colorClasses = 'bg-error-light border-error text-error';
|
|
iconColor = 'text-error';
|
|
break;
|
|
case 'Amber':
|
|
colorClasses = 'bg-warning-light border-warning text-oliver-black';
|
|
iconColor = 'text-warning';
|
|
break;
|
|
case 'Green':
|
|
colorClasses = 'bg-success-light border-success text-success';
|
|
iconColor = 'text-success';
|
|
break;
|
|
case 'Error':
|
|
colorClasses = 'bg-oliver-grey border-grey-300 text-oliver-black';
|
|
iconColor = 'text-oliver-black/60';
|
|
break;
|
|
}
|
|
|
|
const sizeClasses = isLarge ? 'px-4 py-1.5 text-sm rounded-[10px] border shadow-sm' : 'px-2.5 py-1 text-xs rounded-[10px] border shadow-sm';
|
|
|
|
return (
|
|
<div className={`inline-flex items-center font-bold tracking-wide ${sizeClasses} ${colorClasses} backdrop-blur-sm`}>
|
|
{status === 'Red' && <ExclamationTriangleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
|
{status === 'Amber' && <InformationCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
|
{status === 'Green' && <CheckCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
|
{status === 'Error' && <BugIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
|
<span>{status}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ResolveIssueModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (reason: string) => void;
|
|
issueText: string;
|
|
}> = ({ isOpen, onClose, onSubmit, issueText }) => {
|
|
if (!isOpen) return null;
|
|
|
|
const [reason, setReason] = useState('');
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (reason.trim()) {
|
|
onSubmit(reason);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="bg-white rounded-[10px] shadow-2xl p-8 w-full max-w-lg transform transition-all border-2 border-oliver-azure"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-2xl font-bold text-oliver-black mb-2">Resolve Issue</h3>
|
|
<p className="text-oliver-black mb-6">Please provide a reason for manually resolving this issue.</p>
|
|
|
|
<div className="my-6 p-4 bg-oliver-grey border border-grey-300 rounded-[10px] text-oliver-black italic text-sm">
|
|
"{issueText}"
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<label htmlFor="resolution-reason" className="block text-sm font-bold text-oliver-black mb-2">Reason for resolution</label>
|
|
<textarea
|
|
id="resolution-reason"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
className="w-full p-4 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure/50 focus:border-oliver-azure transition-all bg-oliver-grey focus:bg-white resize-none text-oliver-black"
|
|
rows={4}
|
|
placeholder="e.g. 'Legal team has approved this exception via email...'"
|
|
required
|
|
/>
|
|
<div className="mt-8 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-6 py-2.5 rounded-full border-2 border-oliver-azure text-oliver-azure font-semibold hover:bg-oliver-azure hover:text-white transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-2.5 rounded-full bg-oliver-azure text-white font-bold shadow-lg shadow-oliver-azure/30 hover:bg-oliver-azure/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={!reason.trim()}
|
|
>
|
|
Submit Resolution
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FlagIssueModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (comments: string) => void;
|
|
agentName: string;
|
|
}> = ({ isOpen, onClose, onSubmit, agentName }) => {
|
|
if (!isOpen) return null;
|
|
|
|
const [comments, setComments] = useState('');
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
onSubmit(comments);
|
|
setShowSuccess(true);
|
|
setTimeout(() => {
|
|
setShowSuccess(false);
|
|
setComments('');
|
|
onClose();
|
|
}, 2000);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-opacity duration-300"
|
|
onClick={showSuccess ? undefined : onClose}
|
|
>
|
|
<div
|
|
className="bg-white rounded-[10px] shadow-2xl p-8 w-full max-w-lg transform transition-all border-2 border-oliver-azure"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{showSuccess ? (
|
|
<div className="text-center py-8">
|
|
<CheckCircleIcon className="h-12 w-12 text-success mx-auto mb-4" />
|
|
<h3 className="text-xl font-bold text-oliver-black mb-2">Flag Submitted</h3>
|
|
<p className="text-slate-500">Thank you for your feedback on the {agentName}'s review. This has been logged for auditing.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-oliver-black">Flag Feedback</h3>
|
|
<p className="text-slate-500 text-sm mt-1">Reporting incorrect feedback from <span className="font-semibold text-oliver-azure">{agentName}</span></p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors">
|
|
<XIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<label htmlFor="flag-comments" className="block text-sm font-bold text-slate-700 mb-2">Additional Comments</label>
|
|
<textarea
|
|
id="flag-comments"
|
|
value={comments}
|
|
onChange={(e) => setComments(e.target.value)}
|
|
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-red-500/30 focus:border-red-500 transition-all bg-slate-50 focus:bg-white resize-none"
|
|
rows={5}
|
|
placeholder="Please explain why this feedback is incorrect..."
|
|
/>
|
|
<div className="mt-8 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-5 py-2.5 rounded-xl text-slate-600 font-semibold hover:bg-slate-100 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-5 py-2.5 rounded-xl bg-red-600 text-white font-bold shadow-lg shadow-red-600/30 hover:bg-red-500 transition-all"
|
|
>
|
|
Submit Flag
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper function to detect revision mode (when revision data is present)
|
|
const hasRevisionData = (review: SubReview): boolean => {
|
|
return (
|
|
(review.resolvedIssues && review.resolvedIssues.length > 0) ||
|
|
(review.outstandingIssues && review.outstandingIssues.length > 0) ||
|
|
(review.newIssues && review.newIssues.length > 0)
|
|
);
|
|
};
|
|
|
|
const SubReviewCard: React.FC<{
|
|
title: string;
|
|
review: SubReview;
|
|
onFlag: () => void;
|
|
onResolve: (issueText: string, reason: string) => void;
|
|
isFlagged?: boolean;
|
|
resolvedItems?: ResolvedItem[];
|
|
}> = ({ title, review, onFlag, onResolve, isFlagged, resolvedItems = [] }) => {
|
|
interface IssueState {
|
|
text: string;
|
|
status: 'actionable' | 'resolved';
|
|
reason?: string;
|
|
category?: 'outstanding' | 'new'; // For revision mode
|
|
}
|
|
|
|
const [issues, setIssues] = useState<IssueState[]>([]);
|
|
const [currentStatus, setCurrentStatus] = useState<RagStatus>(review.ragStatus);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [activeIssueIndex, setActiveIssueIndex] = useState<number | null>(null);
|
|
|
|
// Section collapse states for revision mode
|
|
const [resolvedCollapsed, setResolvedCollapsed] = useState(true);
|
|
const [outstandingCollapsed, setOutstandingCollapsed] = useState(false);
|
|
const [newCollapsed, setNewCollapsed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const findResolution = (text: string): ResolvedItem | undefined =>
|
|
resolvedItems.find(r => r.issue === text);
|
|
|
|
if (hasRevisionData(review)) {
|
|
// Revision mode: populate from outstandingIssues and newIssues
|
|
const outstandingIssues: IssueState[] = (review.outstandingIssues || []).map(text => {
|
|
const match = findResolution(text);
|
|
return {
|
|
text,
|
|
status: match ? 'resolved' as const : 'actionable' as const,
|
|
reason: match?.resolution,
|
|
category: 'outstanding' as const
|
|
};
|
|
});
|
|
const newIssues: IssueState[] = (review.newIssues || []).map(text => {
|
|
const match = findResolution(text);
|
|
return {
|
|
text,
|
|
status: match ? 'resolved' as const : 'actionable' as const,
|
|
reason: match?.resolution,
|
|
category: 'new' as const
|
|
};
|
|
});
|
|
setIssues([...outstandingIssues, ...newIssues]);
|
|
} else {
|
|
// Original mode: use review.issues
|
|
setIssues(review.issues.map(text => {
|
|
const match = findResolution(text);
|
|
return {
|
|
text,
|
|
status: match ? 'resolved' as const : 'actionable' as const,
|
|
reason: match?.resolution
|
|
};
|
|
}));
|
|
}
|
|
setCurrentStatus(review.ragStatus);
|
|
}, [review, resolvedItems]);
|
|
|
|
useEffect(() => {
|
|
if (review.ragStatus === 'Error') {
|
|
setCurrentStatus('Error');
|
|
return;
|
|
}
|
|
if (issues.length > 0 && issues.every(issue => issue.status === 'resolved')) {
|
|
setCurrentStatus('Green');
|
|
} else if (issues.some(issue => issue.status === 'actionable')) {
|
|
setCurrentStatus(review.ragStatus);
|
|
}
|
|
}, [issues, review.ragStatus]);
|
|
|
|
|
|
const handleOpenModal = (index: number) => {
|
|
setActiveIssueIndex(index);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setActiveIssueIndex(null);
|
|
};
|
|
|
|
const handleResolve = (reason: string) => {
|
|
if (activeIssueIndex === null) return;
|
|
const issueText = issues[activeIssueIndex].text;
|
|
|
|
setIssues(currentIssues =>
|
|
currentIssues.map((issue, index) =>
|
|
index === activeIssueIndex
|
|
? { ...issue, status: 'resolved', reason }
|
|
: issue
|
|
)
|
|
);
|
|
|
|
onResolve(issueText, reason);
|
|
handleCloseModal();
|
|
};
|
|
|
|
const handleReopen = (indexToReopen: number) => {
|
|
setIssues(currentIssues =>
|
|
currentIssues.map((issue, index) =>
|
|
index === indexToReopen
|
|
? { ...issue, status: 'actionable', reason: undefined }
|
|
: issue
|
|
)
|
|
);
|
|
};
|
|
|
|
// Determine styles based on status
|
|
let containerStyles = 'bg-white border-slate-100';
|
|
let gradientOverlay = 'from-slate-50/50 to-white';
|
|
let headerColor = 'text-slate-800';
|
|
let issueIconColor = 'text-slate-400';
|
|
let issueIcon = <InformationCircleIcon className="h-5 w-5 text-slate-400 mt-0.5 flex-shrink-0" />;
|
|
|
|
if (currentStatus === 'Green') {
|
|
containerStyles = 'bg-white border-emerald-100 hover:border-emerald-200';
|
|
gradientOverlay = 'from-emerald-50/40 to-white';
|
|
headerColor = 'text-emerald-900';
|
|
} else if (currentStatus === 'Amber') {
|
|
containerStyles = 'bg-white border-amber-100 hover:border-amber-200';
|
|
gradientOverlay = 'from-amber-50/40 to-white';
|
|
headerColor = 'text-amber-900';
|
|
issueIconColor = 'text-amber-500';
|
|
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />;
|
|
} else if (currentStatus === 'Red') {
|
|
containerStyles = 'bg-white border-red-100 hover:border-red-200';
|
|
gradientOverlay = 'from-red-50/40 to-white';
|
|
headerColor = 'text-red-900';
|
|
issueIconColor = 'text-red-500';
|
|
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ResolveIssueModal
|
|
isOpen={isModalOpen}
|
|
onClose={handleCloseModal}
|
|
onSubmit={handleResolve}
|
|
issueText={activeIssueIndex !== null ? issues[activeIssueIndex].text : ''}
|
|
/>
|
|
<div className={`relative rounded-2xl border p-6 shadow-sm transition-all duration-300 hover:shadow-md ${containerStyles} overflow-hidden group`}>
|
|
{/* Subtle gradient background */}
|
|
<div className={`absolute inset-0 bg-gradient-to-br ${gradientOverlay} pointer-events-none`}></div>
|
|
|
|
<div className="relative z-10">
|
|
<div className="flex justify-between items-center gap-4 mb-5">
|
|
<div className="flex items-center gap-3">
|
|
<h4 className={`text-lg font-bold ${headerColor}`}>{title}</h4>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<RagStatusBadge status={currentStatus} />
|
|
<button
|
|
onClick={onFlag}
|
|
className={`p-1.5 rounded-lg transition-colors ${isFlagged ? 'text-red-500' : 'text-slate-400 hover:bg-red-50 hover:text-red-500'}`}
|
|
title={isFlagged ? "Flagged as incorrect" : "Flag as incorrect"}
|
|
aria-label={isFlagged ? "This feedback has been flagged" : "Flag this feedback as incorrect"}
|
|
>
|
|
<FlagIcon className="h-4 w-4" filled={isFlagged} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-slate-600 text-sm leading-relaxed mb-6">
|
|
{formatFeedbackText(review.feedback)}
|
|
</div>
|
|
|
|
{/* Revision mode: Show categorized sections */}
|
|
{currentStatus !== 'Error' && hasRevisionData(review) && (
|
|
<div className="space-y-4">
|
|
{/* Resolved Issues Section */}
|
|
{review.resolvedIssues && review.resolvedIssues.length > 0 && (
|
|
<div className="bg-emerald-50/50 rounded-xl border border-emerald-100 overflow-hidden">
|
|
<button
|
|
onClick={() => setResolvedCollapsed(!resolvedCollapsed)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-emerald-50/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircleIcon className="h-5 w-5 text-emerald-500" />
|
|
<h5 className="text-xs font-bold uppercase tracking-wider text-emerald-600">
|
|
Resolved Issues
|
|
</h5>
|
|
<span className="bg-emerald-100 text-emerald-700 text-xs font-semibold px-2 py-0.5 rounded-full">
|
|
{review.resolvedIssues.length}
|
|
</span>
|
|
</div>
|
|
<svg
|
|
className={`h-4 w-4 text-emerald-500 transition-transform ${resolvedCollapsed ? '' : 'rotate-180'}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{!resolvedCollapsed && (
|
|
<ul className="px-4 pb-4 space-y-2">
|
|
{review.resolvedIssues.map((issueText, index) => (
|
|
<li key={`resolved-${index}`} className="flex items-start gap-3">
|
|
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
|
<span className="text-sm text-emerald-700 line-through opacity-75">
|
|
{issueText}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Outstanding Issues Section */}
|
|
{issues.filter(i => i.category === 'outstanding').length > 0 && (
|
|
<div className="bg-amber-50/50 rounded-xl border border-amber-100 overflow-hidden">
|
|
<button
|
|
onClick={() => setOutstandingCollapsed(!outstandingCollapsed)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-amber-50/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-amber-500" />
|
|
<h5 className="text-xs font-bold uppercase tracking-wider text-amber-600">
|
|
Outstanding Issues
|
|
</h5>
|
|
<span className="bg-amber-100 text-amber-700 text-xs font-semibold px-2 py-0.5 rounded-full">
|
|
{issues.filter(i => i.category === 'outstanding' && i.status === 'actionable').length}
|
|
</span>
|
|
</div>
|
|
<svg
|
|
className={`h-4 w-4 text-amber-500 transition-transform ${outstandingCollapsed ? '' : 'rotate-180'}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{!outstandingCollapsed && (
|
|
<ul className="px-4 pb-4 space-y-3">
|
|
{issues.filter(i => i.category === 'outstanding').map((issue, idx) => {
|
|
const actualIndex = issues.findIndex((i, index) => i === issue && index >= 0);
|
|
return (
|
|
<li key={`outstanding-${idx}`} className="flex items-start gap-3 group/issue">
|
|
{issue.status === 'actionable' ? (
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
|
) : (
|
|
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
|
)}
|
|
<div className="flex-grow pt-0.5">
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
|
|
<span className={`text-sm ${issue.status === 'resolved' ? 'line-through text-slate-400' : 'text-amber-800'}`}>
|
|
{issue.text}
|
|
</span>
|
|
{issue.status === 'actionable' ? (
|
|
<button
|
|
onClick={() => handleOpenModal(actualIndex)}
|
|
className="flex-shrink-0 opacity-0 group-hover/issue:opacity-100 focus:opacity-100 text-xs font-semibold text-oliver-azure hover:text-oliver-black bg-oliver-azure/10 hover:bg-oliver-azure/20 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Mark Resolved
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative inline-block group/tooltip">
|
|
<InformationCircleIcon className="h-4 w-4 text-slate-300 cursor-help" />
|
|
<div className="absolute bottom-full right-0 mb-2 w-64 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-20 shadow-xl">
|
|
<span className="font-bold block mb-1">Reason for resolution:</span>
|
|
"{issue.reason}"
|
|
</div>
|
|
</span>
|
|
<button
|
|
onClick={() => handleReopen(actualIndex)}
|
|
className="text-xs font-semibold text-slate-400 hover:text-oliver-black bg-slate-100 hover:bg-slate-200 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Re-open
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* New Issues Section */}
|
|
{issues.filter(i => i.category === 'new').length > 0 && (
|
|
<div className="bg-red-50/50 rounded-xl border border-red-100 overflow-hidden">
|
|
<button
|
|
onClick={() => setNewCollapsed(!newCollapsed)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-red-50/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
|
|
<h5 className="text-xs font-bold uppercase tracking-wider text-red-600">
|
|
New Issues
|
|
</h5>
|
|
<span className="bg-red-100 text-red-700 text-xs font-semibold px-2 py-0.5 rounded-full">
|
|
{issues.filter(i => i.category === 'new' && i.status === 'actionable').length}
|
|
</span>
|
|
</div>
|
|
<svg
|
|
className={`h-4 w-4 text-red-500 transition-transform ${newCollapsed ? '' : 'rotate-180'}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{!newCollapsed && (
|
|
<ul className="px-4 pb-4 space-y-3">
|
|
{issues.filter(i => i.category === 'new').map((issue, idx) => {
|
|
const actualIndex = issues.findIndex((i, index) => i === issue && index >= 0);
|
|
return (
|
|
<li key={`new-${idx}`} className="flex items-start gap-3 group/issue">
|
|
{issue.status === 'actionable' ? (
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
|
) : (
|
|
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
|
)}
|
|
<div className="flex-grow pt-0.5">
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
|
|
<span className={`text-sm ${issue.status === 'resolved' ? 'line-through text-slate-400' : 'text-red-800'}`}>
|
|
{issue.text}
|
|
</span>
|
|
{issue.status === 'actionable' ? (
|
|
<button
|
|
onClick={() => handleOpenModal(actualIndex)}
|
|
className="flex-shrink-0 opacity-0 group-hover/issue:opacity-100 focus:opacity-100 text-xs font-semibold text-oliver-azure hover:text-oliver-black bg-oliver-azure/10 hover:bg-oliver-azure/20 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Mark Resolved
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative inline-block group/tooltip">
|
|
<InformationCircleIcon className="h-4 w-4 text-slate-300 cursor-help" />
|
|
<div className="absolute bottom-full right-0 mb-2 w-64 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-20 shadow-xl">
|
|
<span className="font-bold block mb-1">Reason for resolution:</span>
|
|
"{issue.reason}"
|
|
</div>
|
|
</span>
|
|
<button
|
|
onClick={() => handleReopen(actualIndex)}
|
|
className="text-xs font-semibold text-slate-400 hover:text-oliver-black bg-slate-100 hover:bg-slate-200 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Re-open
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No issues state for revision mode */}
|
|
{issues.length === 0 && (!review.resolvedIssues || review.resolvedIssues.length === 0) && (
|
|
<div className="flex items-center p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 text-emerald-700 text-sm">
|
|
<CheckCircleIcon className="h-5 w-5 mr-2" />
|
|
<span className="font-semibold">No key actions identified.</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Original mode: Show single list (backward compatible) */}
|
|
{currentStatus !== 'Error' && !hasRevisionData(review) && issues.length > 0 && (
|
|
<div className="bg-white/50 rounded-xl border border-slate-100 p-4">
|
|
<h5 className={`text-xs font-bold tracking-wider mb-3 ${issueIconColor}`}>Key Actions</h5>
|
|
<ul className="space-y-3">
|
|
{issues.map((issue, index) => (
|
|
<li key={index} className="flex items-start gap-3 group/issue">
|
|
{issue.status === 'actionable' ? (
|
|
issueIcon
|
|
) : (
|
|
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
|
)}
|
|
<div className="flex-grow pt-0.5">
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
|
|
<span className={`text-sm ${issue.status === 'resolved' ? 'line-through text-slate-400' : 'text-slate-700'}`}>
|
|
{issue.text}
|
|
</span>
|
|
{issue.status === 'actionable' ? (
|
|
<button
|
|
onClick={() => handleOpenModal(index)}
|
|
className="flex-shrink-0 opacity-0 group-hover/issue:opacity-100 focus:opacity-100 text-xs font-semibold text-oliver-azure hover:text-oliver-black bg-oliver-azure/10 hover:bg-oliver-azure/20 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Mark Resolved
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative inline-block group/tooltip">
|
|
<InformationCircleIcon className="h-4 w-4 text-slate-300 cursor-help" />
|
|
<div className="absolute bottom-full right-0 mb-2 w-64 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-20 shadow-xl">
|
|
<span className="font-bold block mb-1">Reason for resolution:</span>
|
|
"{issue.reason}"
|
|
</div>
|
|
</span>
|
|
<button
|
|
onClick={() => handleReopen(index)}
|
|
className="text-xs font-semibold text-slate-400 hover:text-oliver-black bg-slate-100 hover:bg-slate-200 px-2.5 py-1 rounded-lg transition-all"
|
|
>
|
|
Re-open
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{currentStatus !== 'Error' && !hasRevisionData(review) && issues.length === 0 && (
|
|
<div className="flex items-center p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 text-emerald-700 text-sm">
|
|
<CheckCircleIcon className="h-5 w-5 mr-2" />
|
|
<span className="font-semibold">No key actions identified.</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const LeadAgentSummary: React.FC<{ status: OverallStatus, summary: string, onFlag: () => void; isFlagged?: boolean; }> = ({ status, summary, onFlag, isFlagged }) => {
|
|
const isPassed = status === 'Passed';
|
|
|
|
let themeStyles = 'from-sky-50 to-white border-sky-100 text-oliver-black';
|
|
let iconBg = 'bg-sky-100 text-oliver-azure';
|
|
let icon = <InformationCircleIcon className="h-8 w-8" />;
|
|
let blobColor = 'bg-sky-400';
|
|
|
|
if (status === 'Passed') {
|
|
themeStyles = 'from-emerald-50 to-white border-emerald-200 text-emerald-900';
|
|
iconBg = 'bg-emerald-100 text-emerald-600';
|
|
icon = <CheckCircleIcon className="h-8 w-8" />;
|
|
blobColor = 'bg-emerald-400';
|
|
} else if (status === 'Failed') {
|
|
themeStyles = 'from-rose-50 to-white border-rose-200 text-rose-900';
|
|
iconBg = 'bg-rose-100 text-rose-600';
|
|
icon = <ExclamationTriangleIcon className="h-8 w-8" />;
|
|
blobColor = 'bg-rose-400';
|
|
}
|
|
|
|
if (status === 'Requires Manual Legal Review') return null;
|
|
|
|
return (
|
|
<div className={`relative overflow-hidden rounded-2xl border p-8 shadow-lg transition-all duration-500 bg-gradient-to-br ${themeStyles}`}>
|
|
{/* Abstract decorative blob */}
|
|
<div className={`absolute -top-12 -right-12 w-48 h-48 rounded-full blur-3xl opacity-20 ${blobColor}`}></div>
|
|
|
|
<div className="relative z-10 flex items-start gap-6">
|
|
<div className={`flex-shrink-0 p-3 rounded-2xl ${iconBg} shadow-sm`}>
|
|
{icon}
|
|
</div>
|
|
<div className="flex-grow">
|
|
<div className="flex justify-between items-start">
|
|
<h3 className="text-2xl font-extrabold tracking-tight mb-3">
|
|
Overall Status: <span className="opacity-90">{status}</span>
|
|
</h3>
|
|
<button
|
|
onClick={onFlag}
|
|
className={`p-2 rounded-full transition-colors ${isFlagged ? 'text-red-500' : 'text-slate-400 hover:text-red-500 hover:bg-white/50'}`}
|
|
title={isFlagged ? "Flagged as incorrect" : "Flag as incorrect"}
|
|
>
|
|
<FlagIcon className="h-5 w-5" filled={isFlagged} />
|
|
</button>
|
|
</div>
|
|
<div className={`text-lg leading-relaxed ${isPassed ? 'text-emerald-800/80' : 'text-slate-700'}`}>
|
|
{formatFeedbackText(summary)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FinancialPromotionSummary: React.FC<{ reason: string; summary: string }> = ({ reason, summary }) => {
|
|
const [exportStatus, setExportStatus] = useState('Export for Legal');
|
|
|
|
const handleExport = () => {
|
|
const textToCopy = `## Financial Promotion Review Required ##\n\nReason for Flag:\n${reason}\n\n## AI Agent Summary ##\n${summary}`.trim();
|
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
setExportStatus('Copied!');
|
|
setTimeout(() => setExportStatus('Export for Legal'), 2000);
|
|
}).catch(err => {
|
|
console.error('Failed to copy text: ', err);
|
|
setExportStatus('Failed');
|
|
setTimeout(() => setExportStatus('Export for Legal'), 2000);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="relative overflow-hidden rounded-2xl border border-purple-200 bg-gradient-to-br from-purple-50 to-white p-8 shadow-lg">
|
|
<div className="absolute -top-20 -right-20 w-64 h-64 rounded-full blur-3xl opacity-20 bg-purple-500"></div>
|
|
|
|
<div className="relative z-10 flex items-start gap-6">
|
|
<div className="flex-shrink-0 p-3 rounded-2xl bg-purple-100 text-purple-600 shadow-sm">
|
|
<LegalIcon className="h-8 w-8" />
|
|
</div>
|
|
<div className="flex-grow">
|
|
<h3 className="text-2xl font-extrabold text-purple-900 tracking-tight mb-2">
|
|
Financial Promotion Detected
|
|
</h3>
|
|
<p className="text-lg text-purple-800/80 mb-6 leading-relaxed">
|
|
This proof has been identified as a financial promotion and requires a separate, manual review from the Barclays legal team.
|
|
</p>
|
|
|
|
<div className="bg-white/60 backdrop-blur-sm border border-purple-100 rounded-xl p-4 mb-6 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-purple-500 mb-1">Reason Identified</p>
|
|
<p className="text-purple-900 font-medium italic">"{reason}"</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleExport}
|
|
className="flex items-center gap-2 bg-purple-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg shadow-purple-600/20 hover:bg-purple-700 hover:shadow-purple-600/30 transition-all duration-300"
|
|
>
|
|
<ExportIcon className="h-5 w-5" />
|
|
{exportStatus}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
export const FeedbackReport: React.FC<{
|
|
feedback: AgentReview;
|
|
onFlagSubmit: (agentName: string, comments: string) => void;
|
|
onResolveSubmit: (agentName: string, issueText: string, reason: string) => void;
|
|
flaggedItems?: FlaggedItem[];
|
|
resolvedItems?: ResolvedItem[];
|
|
proofName?: string;
|
|
version?: number;
|
|
}> = ({ feedback, onFlagSubmit, onResolveSubmit, flaggedItems = [], resolvedItems = [], proofName, version }) => {
|
|
const flaggedAgents = new Set(
|
|
flaggedItems
|
|
.filter(f => f.proofName === proofName && f.version === version)
|
|
.map(f => f.agentFlagged)
|
|
);
|
|
|
|
const filteredResolvedItems = resolvedItems.filter(
|
|
r => r.proofName === proofName && r.version === version
|
|
);
|
|
|
|
const [flagModalState, setFlagModalState] = useState<{ isOpen: boolean; agentName: string }>({
|
|
isOpen: false,
|
|
agentName: '',
|
|
});
|
|
|
|
const handleOpenFlagModal = (agentName: string) => {
|
|
setFlagModalState({ isOpen: true, agentName });
|
|
};
|
|
|
|
const handleCloseFlagModal = () => {
|
|
setFlagModalState({ isOpen: false, agentName: '' });
|
|
};
|
|
|
|
const handleSubmitFlag = (comments: string) => {
|
|
onFlagSubmit(flagModalState.agentName, comments);
|
|
};
|
|
|
|
const agentReviews = [
|
|
{ title: 'Risk & Control Agent', review: feedback.legalAgentReview },
|
|
{ title: 'Brand Agent', review: feedback.brandAgentReview },
|
|
{ title: 'Channel Best Practices Agent', review: feedback.channelBestPracticesAgentReview },
|
|
{ title: 'Channel Tech Specs Agent', review: feedback.channelTechSpecsAgentReview },
|
|
];
|
|
|
|
const isFinancialPromotion = feedback.overallStatus === 'Requires Manual Legal Review';
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<FlagIssueModal
|
|
isOpen={flagModalState.isOpen}
|
|
onClose={handleCloseFlagModal}
|
|
onSubmit={handleSubmitFlag}
|
|
agentName={flagModalState.agentName}
|
|
/>
|
|
|
|
{isFinancialPromotion ? (
|
|
<FinancialPromotionSummary
|
|
reason={feedback.financialPromotionReason || "No specific reason provided."}
|
|
summary={feedback.leadAgentSummary}
|
|
/>
|
|
) : (
|
|
<LeadAgentSummary
|
|
status={feedback.overallStatus}
|
|
summary={feedback.leadAgentSummary}
|
|
onFlag={() => handleOpenFlagModal('Lead Agent')}
|
|
isFlagged={flaggedAgents.has('Lead Agent')}
|
|
/>
|
|
)}
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-6">
|
|
{agentReviews.map(({ title, review }) => (
|
|
<SubReviewCard
|
|
key={title}
|
|
title={`${title}`}
|
|
review={review}
|
|
onFlag={() => handleOpenFlagModal(title)}
|
|
onResolve={(issueText, reason) => onResolveSubmit(title, issueText, reason)}
|
|
isFlagged={flaggedAgents.has(title)}
|
|
resolvedItems={filteredResolvedItems.filter(r => r.agent === title)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |