modcomms/frontend/components/PDFReport.tsx
Vadym Samoilenko aeab7d3b18 Rename Legal Agent to Risk & Control Agent across frontend and backend
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>
2026-05-14 15:10:32 +01:00

230 lines
No EOL
13 KiB
TypeScript
Executable file

import React from 'react';
import type { AgentReview, RagStatus } from '../types';
import { LegalIcon } from './icons/LegalIcon';
import { BrandIcon } from './icons/BrandIcon';
import { ChannelIcon } from './icons/ChannelIcon';
interface PDFReportProps {
campaignName: string;
proofs: any[];
}
/**
* Renders inline markdown bold (**text**) as <strong> elements for PDF output.
*/
const renderBoldMarkdownForPDF = (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 formatFeedbackTextForPDF = (text: string): React.ReactNode => {
if (!text) return null;
// Normalize HTML tags and bullet characters
let normalizedText = text
.replace(/<\/li>/gi, '\n')
.replace(/<\/ul>/gi, '\n')
.replace(/<ul[^>]*>/gi, '')
.replace(/<li[^>]*>/gi, '• ')
.replace(/<[^>]+>/g, '')
.replace(/\s*•\s*/g, '\n• ')
.replace(/\n{2,}/g, '\n')
.trim();
const lines = normalizedText.split('\n').filter(line => line.trim());
// 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('•')) {
bulletGroups.push([trimmed.replace(/^•\s*/, '').trim()]);
} else if (trimmed) {
if (bulletGroups.length === 0) {
introLines.push(trimmed);
} else {
// Continuation line within the current bullet
bulletGroups[bulletGroups.length - 1].push(trimmed);
}
}
});
return (
<>
{introLines.length > 0 && (
<p style={{ margin: '0 0 8px 0' }}>{renderBoldMarkdownForPDF(introLines.join(' '))}</p>
)}
{bulletGroups.length > 0 && (
<ul style={{ margin: 0, paddingLeft: '20px', listStyleType: 'disc', listStylePosition: 'outside' }}>
{bulletGroups.map((group, index) => (
<li key={index} style={{ marginBottom: '8px', lineHeight: '1.5', breakInside: 'avoid' }}>
{group.map((line, lineIdx) => (
<React.Fragment key={lineIdx}>
{lineIdx > 0 && <br />}
{renderBoldMarkdownForPDF(line)}
</React.Fragment>
))}
</li>
))}
</ul>
)}
</>
);
};
const RagStatusBadge: React.FC<{ status: RagStatus }> = ({ status }) => {
let bgColor = '#E5E7EB'; // Gray for Error
let textColor = '#1F2937';
switch (status) {
case 'Red': bgColor = '#FEE2E2'; textColor = '#991B1B'; break;
case 'Amber': bgColor = '#FEF3C7'; textColor = '#92400E'; break;
case 'Green': bgColor = '#D1FAE5'; textColor = '#065F46'; break;
}
return (
<span style={{ backgroundColor: bgColor, borderRadius: '9999px', display: 'inline-block', minWidth: '50px', paddingLeft: '12px', paddingRight: '12px', paddingTop: '4px', paddingBottom: '4px', lineHeight: '1.2', textAlign: 'center', color: textColor, fontSize: '12px', fontWeight: 'bold' }}>
{status}
</span>
);
};
export const PDFReport: React.FC<PDFReportProps> = ({ campaignName, proofs }) => {
const logoUrl = `${window.location.origin}${import.meta.env.BASE_URL}BAR-ModComms-logo-v6.png`;
const today = new Date().toLocaleDateString('en-GB', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const isCampaignReport = proofs.length > 1;
const singleProofName = !isCampaignReport && proofs.length > 0 ? proofs[0].proofName : '';
return (
<div style={{ width: '210mm', fontFamily: 'Arial, sans-serif', color: '#333333', background: '#FFFFFF' }}>
{/* --- Cover Page --- */}
<div style={{ width: '210mm', height: '297mm', display: 'flex', flexDirection: 'column', padding: '20mm', boxSizing: 'border-box', borderBottom: '1px solid #e5e5e5' }}>
<div style={{ display: 'flex', alignItems: 'center', paddingBottom: '10mm', borderBottom: '1px solid #e5e5e5' }}>
<div style={{ backgroundColor: '#000000', padding: '10px 16px', borderRadius: '6px', display: 'inline-flex' }}>
<img src={logoUrl} alt="Mod Comms AI — In partnership with Barclays" style={{ height: '60px', width: 'auto' }} />
</div>
</div>
<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<h1 style={{ fontSize: '42px', color: '#001f5a', margin: '0 0 10px 0' }}>AI Compliance & Brand Report</h1>
<p style={{ fontSize: '18px', color: '#555', marginTop: '0' }}>
{isCampaignReport ? 'Campaign-Level Summary' : 'Single Proof Analysis'}
</p>
</div>
<div style={{ borderTop: '1px solid #e5e5e5', paddingTop: '10mm' }}>
<p style={{ margin: 0, fontSize: '14px' }}><strong>Campaign:</strong> {campaignName}</p>
{!isCampaignReport && <p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Proof:</strong> {singleProofName}</p>}
<p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Export Date:</strong> {today}</p>
</div>
</div>
{/* --- Table of Contents for Campaign Report --- */}
{isCampaignReport && (
<div style={{ width: '210mm', minHeight: '297mm', padding: '20mm', boxSizing: 'border-box', pageBreakBefore: 'always', borderBottom: '1px solid #e5e5e5' }}>
<h2 style={{ fontSize: '28px', color: '#001f5a', borderBottom: '2px solid #00a3e0', paddingBottom: '8px', marginBottom: '20px' }}>
Table of Contents
</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{proofs.map((proof, index) => (
<li key={index} style={{ fontSize: '16px', padding: '10px 0', borderBottom: '1px dotted #ccc' }}>
{proof.proofName} - V{proof.versions[0]?.version || 1}
</li>
))}
</ul>
</div>
)}
{/* --- Proof Report Pages --- */}
{proofs.map((proof) => {
const version = proof.versions[0];
if (!version) return null;
const feedback: AgentReview = version.feedback;
const agentReviews = [
{ title: 'Risk & Control Agent', review: feedback.legalAgentReview, icon: <LegalIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Brand Agent', review: feedback.brandAgentReview, icon: <BrandIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Channel Best Practices Agent', review: feedback.channelBestPracticesAgentReview, icon: <ChannelIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Channel Tech Specs Agent', review: feedback.channelTechSpecsAgentReview, icon: <ChannelIcon style={{height: '24px', width: '24px'}} /> },
];
return (
<div key={proof.proofName} style={{ width: '210mm', minHeight: '297mm', padding: '15mm', boxSizing: 'border-box', pageBreakBefore: 'always' }}>
{/* Proof Header */}
<div style={{ paddingBottom: '8px', borderBottom: '2px solid #00a3e0', marginBottom: '10mm' }}>
<h2 style={{ fontSize: '24px', color: '#001f5a', margin: 0 }}>{proof.proofName} - V{version.version}</h2>
<p style={{ fontSize: '14px', color: '#555', margin: '4px 0 0 0' }}>
{version.workfrontId} &bull; {proof.channel} / {proof.subChannel}
</p>
</div>
{/* Preview & Summary */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10mm', marginBottom: '10mm', breakInside: 'avoid' }}>
<div style={{ border: '1px solid #ccc', padding: '4px', background: '#f8f8f8' }}>
<img src={version.proofPreviewUrl} alt="Proof Preview" style={{ width: '100%', objectFit: 'contain' }} />
</div>
<div style={{ backgroundColor: '#f4f6f8', padding: '6mm', borderRadius: '4px', borderLeft: `4px solid ${feedback.overallStatus === 'Passed' ? '#10B981' : '#EF4444'}`}}>
<h3 style={{ fontSize: '18px', margin: '0 0 8px 0', color: '#001f5a' }}>Overall Summary</h3>
<p style={{ fontSize: '14px', margin: '0 0 10px 0', fontWeight: 'bold' }}>Status: {feedback.overallStatus}</p>
{feedback.overallStatus === 'Requires Manual Legal Review' && (
<div style={{ backgroundColor: '#F3E8FF', border: '1px solid #D8B4FE', borderRadius: '4px', padding: '8px', marginBottom: '10px'}}>
<p style={{fontSize: '13px', margin: 0, fontWeight: 'bold', color: '#6B21A8'}}>Financial Promotion Detected:</p>
<p style={{fontSize: '12px', margin: '4px 0 0 0', color: '#6B21A8', fontStyle: 'italic'}}>"{feedback.financialPromotionReason}"</p>
</div>
)}
<div style={{ fontSize: '14px', lineHeight: 1.5, margin: 0 }}>{formatFeedbackTextForPDF(feedback.leadAgentSummary)}</div>
</div>
</div>
{/* Detailed Agent Feedback */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm', breakInside: 'avoid' }}>
{agentReviews.map(({ title, review, icon }) => (
<div key={title} style={{ border: '1px solid #e5e5e5', borderRadius: '4px', padding: '5mm', backgroundColor: '#fff', breakInside: 'avoid', pageBreakInside: 'avoid' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<h4 style={{ fontSize: '16px', fontWeight: 'bold', color: '#001f5a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px'}}>
<span style={{color: '#0070c0'}}>{icon}</span> {title}
</h4>
<RagStatusBadge status={review.ragStatus} />
</div>
<div style={{ fontSize: '13px', lineHeight: 1.5, margin: '0 0 10px 0', borderTop: '1px solid #eee', paddingTop: '10px' }}>{formatFeedbackTextForPDF(review.feedback)}</div>
{review.issues && review.issues.length > 0 && (
<div>
<h5 style={{ fontSize: '13px', fontWeight: 'bold', margin: '0 0 5px 0' }}>Key Actions:</h5>
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.5, listStyleType: 'disc', listStylePosition: 'outside' }}>
{review.issues.map((issue, i) => <li key={i} style={{ marginBottom: '4px', lineHeight: '1.5', breakInside: 'avoid' }}>{issue}</li>)}
</ul>
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
);
};