modcomms/frontend/components/FeedbackReport.tsx
michael 17495d4291 Fix feedback report formatting by parsing HTML and bullet text
Add formatFeedbackText() utility that converts raw HTML tags and
concatenated bullet points from Gemini API into properly formatted
React elements with proper line breaks and list styling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 08:19:28 -06:00

605 lines
No EOL
28 KiB
TypeScript
Executable file

import React, { useState, useEffect } from 'react';
import type { AgentReview, SubReview, RagStatus, OverallStatus } 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.
*/
const formatFeedbackText = (text: string): React.ReactNode => {
if (!text) return null;
// First, handle HTML tags by converting them to a normalized format
let normalizedText = text
// 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
.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
const bulletLines: string[] = [];
const introLines: string[] = [];
lines.forEach(line => {
const trimmed = line.trim();
if (trimmed.startsWith('•')) {
// Remove the bullet character, we'll add it via CSS
bulletLines.push(trimmed.replace(/^•\s*/, '').trim());
} else if (trimmed) {
// If we haven't started collecting bullets yet, it's intro text
if (bulletLines.length === 0) {
introLines.push(trimmed);
} else {
// Text after bullets - treat as continuation of last bullet
const lastBullet = bulletLines.pop();
if (lastBullet) {
bulletLines.push(`${lastBullet} ${trimmed}`);
} else {
bulletLines.push(trimmed);
}
}
}
});
return (
<>
{introLines.length > 0 && (
<p className="mb-3">{introLines.join(' ')}</p>
)}
{bulletLines.length > 0 && (
<ul className="list-disc list-inside space-y-1">
{bulletLines.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)}
</>
);
};
const RagStatusBadge: React.FC<{ status: RagStatus; isLarge?: boolean }> = ({ status, isLarge = false }) => {
let colorClasses = '';
let iconColor = '';
switch (status) {
case 'Red':
colorClasses = 'bg-red-50 border-red-200 text-red-800 shadow-red-100';
iconColor = 'text-red-600';
break;
case 'Amber':
colorClasses = 'bg-amber-50 border-amber-200 text-amber-800 shadow-amber-100';
iconColor = 'text-amber-600';
break;
case 'Green':
colorClasses = 'bg-emerald-50 border-emerald-200 text-emerald-800 shadow-emerald-100';
iconColor = 'text-emerald-600';
break;
case 'Error':
colorClasses = 'bg-slate-50 border-slate-200 text-slate-700 shadow-slate-100';
iconColor = 'text-slate-500';
break;
}
const sizeClasses = isLarge ? 'px-4 py-1.5 text-sm rounded-xl border shadow-sm' : 'px-2.5 py-1 text-xs rounded-lg 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-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-2xl font-bold text-brand-dark-blue mb-2">Resolve Issue</h3>
<p className="text-slate-600 mb-6">Please provide a reason for manually resolving this issue.</p>
<div className="my-6 p-4 bg-slate-50 border border-slate-200 rounded-xl text-slate-700 italic text-sm">
"{issueText}"
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="resolution-reason" className="block text-sm font-bold text-slate-700 mb-2">Reason for resolution</label>
<textarea
id="resolution-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-brand-accent/50 focus:border-brand-accent transition-all bg-slate-50 focus:bg-white resize-none"
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-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-brand-accent text-white font-bold shadow-lg shadow-brand-accent/30 hover:bg-brand-light-blue 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 handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(comments);
};
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={onClose}
>
<div
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-2xl font-bold text-brand-dark-blue">Flag Feedback</h3>
<p className="text-slate-500 text-sm mt-1">Reporting incorrect feedback from <span className="font-semibold text-brand-accent">{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>
);
};
const SubReviewCard: React.FC<{
title: string;
review: SubReview;
onFlag: () => void;
onResolve: (issueText: string, reason: string) => void;
}> = ({ title, review, onFlag, onResolve }) => {
interface IssueState {
text: string;
status: 'actionable' | 'resolved';
reason?: string;
}
const [issues, setIssues] = useState<IssueState[]>([]);
const [currentStatus, setCurrentStatus] = useState<RagStatus>(review.ragStatus);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeIssueIndex, setActiveIssueIndex] = useState<number | null>(null);
useEffect(() => {
setIssues(review.issues.map(text => ({ text, status: 'actionable', reason: undefined })));
setCurrentStatus(review.ragStatus);
}, [review]);
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 text-slate-400 rounded-lg hover:bg-red-50 hover:text-red-500 transition-colors"
title="Flag as incorrect"
aria-label="Flag this feedback as incorrect"
>
<FlagIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="text-slate-600 text-sm leading-relaxed mb-6">
{formatFeedbackText(review.feedback)}
</div>
{currentStatus !== 'Error' && issues.length > 0 && (
<div className="bg-white/50 rounded-xl border border-slate-100 p-4">
<h5 className={`text-xs font-bold uppercase tracking-wider mb-3 ${issueIconColor}`}>Actionable Issues</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-brand-accent hover:text-brand-dark-blue bg-brand-light-blue/10 hover:bg-brand-light-blue/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-brand-dark-blue 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' && 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 actionable issues found.</span>
</div>
)}
</div>
</div>
</>
);
};
const LeadAgentSummary: React.FC<{ status: OverallStatus, summary: string, onFlag: () => void; }> = ({ status, summary, onFlag }) => {
const isPassed = status === 'Passed';
let themeStyles = 'from-sky-50 to-white border-sky-100 text-brand-dark-blue';
let iconBg = 'bg-sky-100 text-brand-accent';
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="text-slate-400 hover:text-red-500 p-2 rounded-full hover:bg-white/50 transition-colors"
title="Flag as incorrect"
>
<FlagIcon className="h-5 w-5" />
</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;
}> = ({ feedback, onFlagSubmit, onResolveSubmit }) => {
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);
alert(`Thank you for your feedback on the ${flagModalState.agentName}'s review. This has been logged for auditing.`);
handleCloseFlagModal();
};
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')}
/>
)}
<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)}
/>
))}
</div>
</div>
);
};