All modal inner containers now have border-2 border-oliver-azure for consistent Oliver branding across: - CreateCampaignModal, CreateProjectModal - FeedbackReport (resolve + flag modals) - UserManagement (confirmation + history modals) - Campaigns (upload, delete confirmation, version history modals) - Projects (upload, delete modals) - Login (support contact modal) Co-Authored-By: Claude Opus 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: 'Legal 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>
|
|
);
|
|
}; |