1327 lines
73 KiB
TypeScript
Executable file
1327 lines
73 KiB
TypeScript
Executable file
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import { PlusIcon } from './icons/PlusIcon';
|
|
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
|
|
import type { AgentReview, FlaggedItem, ResolvedItem, OverallStatus } from '../types';
|
|
import { FeedbackReport } from './FeedbackReport';
|
|
import { CreateCampaignModal } from './CreateCampaignModal';
|
|
import { CheckCircleIcon, ArrowPathIcon } from './icons/StatusIcons';
|
|
import { ProofPreview } from './ProofPreview';
|
|
import { HistoryIcon } from './icons/HistoryIcon';
|
|
import { DropdownOptions } from '../App';
|
|
import { ChannelIcon } from './icons/ChannelIcon';
|
|
import { TagIcon } from './icons/TagIcon';
|
|
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
|
import { UploadIcon } from './icons/UploadIcon';
|
|
import { DocumentIcon } from './icons/DocumentIcon';
|
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
|
import { AGENT_NAMES } from '../constants';
|
|
import { ToggleSwitch } from './ToggleSwitch';
|
|
import { TrashIcon } from './icons/TrashIcon';
|
|
import { DownloadIcon } from './icons/DownloadIcon';
|
|
import { PDFIcon } from './icons/PDFIcon';
|
|
import { PDFReport } from './PDFReport';
|
|
import { ExportIcon } from './icons/ExportIcon';
|
|
import { XIcon } from './icons/XIcon';
|
|
|
|
|
|
export const initialCampaigns = [
|
|
{
|
|
name: 'Barclays Q4 campaign',
|
|
workfrontId: '#WF_12822',
|
|
clientLead: 'Jane Doe',
|
|
agency: 'OLIVER Agency',
|
|
agencyLead: 'Steve O\'Donoghue',
|
|
proofs: 3,
|
|
status: 'In Progress',
|
|
lastModified: '2024-07-22',
|
|
brandGuidelines: 'Barclays',
|
|
},
|
|
{
|
|
name: 'Barclays Q3 Roundup',
|
|
workfrontId: '#WF_12750',
|
|
clientLead: 'Jane Doe',
|
|
agency: 'OLIVER Agency',
|
|
agencyLead: 'Steve O\'Donoghue',
|
|
proofs: 1,
|
|
status: 'Completed',
|
|
lastModified: '2024-06-30',
|
|
brandGuidelines: 'Barclays',
|
|
},
|
|
];
|
|
|
|
const IG_HERO_POST_1_IMAGE_V1 = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxMDgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiMwMDFmNWEiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2lçZz0iODAiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj5JRyBIZXJvIFBvc3QgMSB2MVc8L3RleHQ+PHRleHQgeD0iNTAlIiB5PSI2MCUiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjQwIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+KEFzc2V0IFByZXZpZXcpPC90ZXh0Pjwvc3ZnPg==';
|
|
const IG_HERO_POST_1_IMAGE_V2 = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxMDgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiMwMDcwYzAiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj5JRyBIZXJvIFBvc3QgMSB2MjwvdGV4dD48dGV4dCB4PSI1MCUiIHk9IjYwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNDAiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj4oQ29ycmVjdGlvbnMgQXBwbGllZCk8L3RleHQ+PC9zdmc+';
|
|
|
|
const IG_HERO_POST_1_FEEDBACK_V1: AgentReview = {
|
|
overallStatus: "Analysis Error",
|
|
leadAgentSummary: "The proof could not be reliably processed by some agents due to low confidence in the analysis. This may happen if the proof is too abstract or irrelevant to typical marketing content. This version has been logged for manual human review. Please try uploading a revised version of the proof.",
|
|
legalAgentReview: {
|
|
ragStatus: "Error",
|
|
feedback: "The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.",
|
|
issues: []
|
|
},
|
|
brandAgentReview: {
|
|
ragStatus: "Error",
|
|
feedback: "The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.",
|
|
issues: []
|
|
},
|
|
toneAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "The copy is clear, concise, and professional. The tone aligns well with the brand's voice and effectively communicates the new partnership.",
|
|
issues: []
|
|
},
|
|
channelAgentReview: {
|
|
ragStatus: "Amber",
|
|
feedback: "While the image quality is high, the composition is not optimized for vertical formats like Instagram Stories. The high ratio of text in the image could also potentially reduce advertising reach on some platforms.",
|
|
issues: ["Image composition is not mobile-first or suitable for vertical formats.", "High ratio of text in the image may negatively impact ad performance."]
|
|
}
|
|
};
|
|
|
|
const IG_HERO_POST_1_FEEDBACK_V2: AgentReview = {
|
|
overallStatus: "Passed",
|
|
leadAgentSummary: "The proof has Passed review. All critical legal and brand issues from the previous version have been successfully addressed. The partnership claim is clear, and the design now fully aligns with brand guidelines. One minor suggestion is to consider a separate vertical version for Story placements, but the proof is approved for deployment.",
|
|
legalAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "All legal concerns have been addressed. The partnership claim is now clearly stated as 'Proud principal partner' and all necessary documentation for the use of the Lord's branding has been verified.",
|
|
issues: []
|
|
},
|
|
brandAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "The proof now fully aligns with brand guidelines. The 'Principal partner' lockup uses the correct brand font, and the non-standard blue border has been removed, resulting in a cleaner, on-brand look.",
|
|
issues: []
|
|
},
|
|
toneAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "The copy remains clear, professional, and well-aligned with the brand's tone of voice. No issues found.",
|
|
issues: []
|
|
},
|
|
channelAgentReview: {
|
|
ragStatus: "Amber",
|
|
feedback: "The image composition has been improved and is suitable for standard feeds. However, for optimal performance on Instagram, creating a separate vertical version for Story placements is recommended.",
|
|
issues: ["Consider creating a separate vertical version for Story placements."]
|
|
}
|
|
};
|
|
|
|
export const initialCampaignProofs: { [key: string]: any[] } = {
|
|
'Barclays Q4 campaign': [
|
|
{
|
|
proofName: 'IG Hero Post 1',
|
|
channel: 'Social',
|
|
subChannel: 'Meta',
|
|
proofType: 'In-feed 4x5',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 2,
|
|
timestamp: '2024-07-25',
|
|
workfrontId: '#WF_12823-V2',
|
|
proofPreviewUrl: IG_HERO_POST_1_IMAGE_V2,
|
|
feedback: IG_HERO_POST_1_FEEDBACK_V2,
|
|
overallStatus: 'Passed',
|
|
},
|
|
{
|
|
version: 1,
|
|
timestamp: '2024-07-23',
|
|
workfrontId: '#WF_12823-V1',
|
|
proofPreviewUrl: IG_HERO_POST_1_IMAGE_V1,
|
|
feedback: IG_HERO_POST_1_FEEDBACK_V1,
|
|
overallStatus: 'Analysis Error',
|
|
}
|
|
]
|
|
},
|
|
{
|
|
proofName: 'Q4 FB Static 4x5',
|
|
channel: 'Social',
|
|
subChannel: 'Meta',
|
|
proofType: 'In-feed 4x5',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 1,
|
|
timestamp: '2024-07-26',
|
|
workfrontId: '#WF_12824-V1',
|
|
proofPreviewUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxMzUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiMwMDcwYzAiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj5GQiBTdGF0aWMgNHg1PC90ZXh0Pjwvc3ZnPg==',
|
|
feedback: {
|
|
overallStatus: "Passed",
|
|
leadAgentSummary: "Passed. Compliant with all guidelines.",
|
|
legalAgentReview: { ragStatus: "Green", feedback: "Disclaimers present and correct.", issues: [] },
|
|
brandAgentReview: { ragStatus: "Green", feedback: "On brand colors used.", issues: [] },
|
|
toneAgentReview: { ragStatus: "Green", feedback: "Professional tone.", issues: [] },
|
|
channelAgentReview: { ragStatus: "Green", feedback: "Correct specs for FB.", issues: [] }
|
|
},
|
|
overallStatus: 'Passed',
|
|
}
|
|
]
|
|
},
|
|
{
|
|
proofName: 'Q4 IG Story Promo',
|
|
channel: 'Social',
|
|
subChannel: 'Meta',
|
|
proofType: 'Stories Static 9x16',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 1,
|
|
timestamp: '2024-07-27',
|
|
workfrontId: '#WF_12825-V1',
|
|
proofPreviewUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxOTIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiMwMDFmNWEiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj5JRyBTdG9yeTwvdGV4dD48L3N2Zz4=',
|
|
feedback: {
|
|
overallStatus: "Passed",
|
|
leadAgentSummary: "Approved for IG Stories.",
|
|
legalAgentReview: { ragStatus: "Green", feedback: "Compliant.", issues: [] },
|
|
brandAgentReview: { ragStatus: "Green", feedback: "Brand aligned.", issues: [] },
|
|
toneAgentReview: { ragStatus: "Green", feedback: "Engaging copy.", issues: [] },
|
|
channelAgentReview: { ragStatus: "Green", feedback: "Optimized for mobile.", issues: [] }
|
|
},
|
|
overallStatus: 'Passed',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
'Barclays Q3 Roundup': [
|
|
{
|
|
proofName: 'Q3 Results Infographic',
|
|
channel: 'Display',
|
|
subChannel: 'Banner',
|
|
proofType: '300x600',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 1,
|
|
timestamp: '2024-06-28',
|
|
workfrontId: '#WF_12751-V1',
|
|
proofPreviewUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxMDgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNmNGY2ZjgiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZpbGw9IiMwMDFmNWEiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZpbGw9IiMwMDFmNWEiIHRleH0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIj5RMyBSZXN1bHRzIEluZm9ncmFwaGljPC90ZXh0Pjwvc3ZnPg==',
|
|
feedback: {
|
|
overallStatus: "Passed",
|
|
leadAgentSummary: "The proof has Passed review with no issues found across all categories. It is well-designed, compliant, and ready for deployment.",
|
|
legalAgentReview: { ragStatus: "Green", feedback: "All claims are substantiated and disclaimers are correctly placed.", issues: [] },
|
|
brandAgentReview: { ragStatus: "Green", feedback: "The proof adheres perfectly to all brand guidelines.", issues: [] },
|
|
toneAgentReview: { ragStatus: "Green", feedback: "The copy is clear, accurate, and professional.", issues: [] },
|
|
channelAgentReview: { ragStatus: "Green", feedback: "The infographic format is highly suitable for the selected channel.", issues: [] },
|
|
},
|
|
overallStatus: 'Passed',
|
|
}
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
|
let colorClasses = '';
|
|
switch (status) {
|
|
case 'In Progress':
|
|
colorClasses = 'bg-blue-100 text-blue-800';
|
|
break;
|
|
case 'Completed':
|
|
colorClasses = 'bg-green-100 text-green-800';
|
|
break;
|
|
case 'Needs Review':
|
|
colorClasses = 'bg-yellow-100 text-yellow-800';
|
|
break;
|
|
default:
|
|
colorClasses = 'bg-gray-100 text-gray-800';
|
|
}
|
|
return (
|
|
<span className={`px-2.5 py-0.5 text-xs font-semibold rounded-full ${colorClasses}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const OverallStatusBadge: React.FC<{ status: OverallStatus }> = ({ status }) => {
|
|
let colorClasses = '';
|
|
switch (status) {
|
|
case 'Passed':
|
|
colorClasses = 'bg-green-100 text-green-800';
|
|
break;
|
|
case 'Failed':
|
|
colorClasses = 'bg-red-100 text-red-800';
|
|
break;
|
|
case 'Requires Manual Legal Review':
|
|
colorClasses = 'bg-purple-100 text-purple-800';
|
|
break;
|
|
case 'Analysis Error':
|
|
colorClasses = 'bg-gray-200 text-gray-800';
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<span className={`inline-flex items-center px-3 py-1 text-sm font-bold rounded-full ${colorClasses}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
|
|
const CampaignList: React.FC<{
|
|
onSelectCampaign: (name: string) => void;
|
|
campaigns: typeof initialCampaigns;
|
|
onOpenModal: () => void;
|
|
onCampaignStatusChange: (campaignName: string, newStatus: 'In Progress' | 'Completed') => void;
|
|
}> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange }) => {
|
|
const [showCompleted, setShowCompleted] = useState(true);
|
|
|
|
const filteredCampaigns = showCompleted ? campaigns : campaigns.filter(p => p.status !== 'Completed');
|
|
|
|
const getStatusSelectClasses = (status: string) => {
|
|
let baseClasses = 'w-full text-center px-2.5 py-0.5 text-xs font-semibold rounded-full border border-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none cursor-pointer';
|
|
let colorClasses = '';
|
|
switch (status) {
|
|
case 'In Progress':
|
|
colorClasses = 'bg-blue-100 text-blue-800';
|
|
break;
|
|
case 'Completed':
|
|
colorClasses = 'bg-green-100 text-green-800';
|
|
break;
|
|
default:
|
|
colorClasses = 'bg-gray-100 text-gray-800';
|
|
}
|
|
return `${baseClasses} ${colorClasses}`;
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
|
<header className="mb-8">
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Campaigns</h1>
|
|
<p className="text-base lg:text-lg text-gray-600 mt-1">Manage your campaigns and proof collections.</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="show-completed" className="text-sm font-medium text-gray-700 whitespace-nowrap">Show Completed</label>
|
|
<ToggleSwitch enabled={showCompleted} onChange={setShowCompleted} />
|
|
</div>
|
|
<button
|
|
onClick={onOpenModal}
|
|
className="flex items-center gap-2 bg-brand-accent text-white font-semibold py-2 px-4 rounded-lg hover:bg-brand-dark-blue transition-colors duration-300"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Create New Campaign
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section>
|
|
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Campaign Name</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proofs</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Created By</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Owning Agency</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Last Modified</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredCampaigns.map((campaign) => {
|
|
return (
|
|
<tr key={campaign.name} className="hover:bg-gray-100 cursor-pointer" onClick={() => onSelectCampaign(campaign.name)}>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{campaign.name}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{campaign.proofs}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm w-40">
|
|
<div className="relative">
|
|
<select
|
|
value={campaign.status}
|
|
onChange={(e) => onCampaignStatusChange(campaign.name, e.target.value as 'In Progress' | 'Completed')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={getStatusSelectClasses(campaign.status)}
|
|
aria-label={`Change status for ${campaign.name}`}
|
|
>
|
|
<option value="In Progress">In Progress</option>
|
|
<option value="Completed">Completed</option>
|
|
</select>
|
|
<ChevronDownIcon className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2 h-full w-4 text-gray-500" />
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{campaign.agencyLead}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{campaign.agency}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{campaign.lastModified}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UploadProofModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
dropdownOptions: DropdownOptions;
|
|
onSubmit: (file: File, proofName: string, channel: string, subChannel: string, proofType?: string) => void;
|
|
isLoading: boolean;
|
|
existingProofNames: string[];
|
|
}> = ({ isOpen, onClose, dropdownOptions, onSubmit, isLoading, existingProofNames }) => {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [proofName, setProofName] = useState('');
|
|
const [channel, setChannel] = useState('');
|
|
const [subChannel, setSubChannel] = useState('');
|
|
const [proofType, setProofType] = useState('');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const availableChannels = Object.keys(dropdownOptions.channels);
|
|
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
|
|
const availableProofTypes = (channel && subChannel) ? (dropdownOptions.channels[channel][subChannel] || []) : [];
|
|
const showProofType = availableProofTypes.length > 0;
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setFile(null);
|
|
setProofName('');
|
|
setChannel('');
|
|
setSubChannel('');
|
|
setProofType('');
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Reset dependents when parent changes
|
|
useEffect(() => {
|
|
setSubChannel('');
|
|
setProofType('');
|
|
}, [channel]);
|
|
|
|
useEffect(() => {
|
|
if (!showProofType) {
|
|
setProofType('');
|
|
} else {
|
|
setProofType('');
|
|
}
|
|
}, [subChannel, showProofType]);
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFile = event.target.files?.[0];
|
|
if (selectedFile) {
|
|
setFile(selectedFile);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!isLoading && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
setFile(e.dataTransfer.files[0]);
|
|
e.dataTransfer.clearData();
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (file && proofName && channel && subChannel) {
|
|
if (showProofType && !proofType) return;
|
|
onSubmit(file, proofName, channel, subChannel, showProofType ? proofType : undefined);
|
|
}
|
|
};
|
|
|
|
const isSubmitDisabled = !proofName || !file || !channel || !subChannel || (showProofType && !proofType) || isLoading;
|
|
const isNewVersion = existingProofNames.includes(proofName.trim()) && proofName.trim() !== '';
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
|
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
|
<div
|
|
className="fixed inset-0 bg-slate-900/75 backdrop-blur-sm transition-opacity"
|
|
aria-hidden="true"
|
|
onClick={onClose}
|
|
></div>
|
|
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">​</span>
|
|
|
|
<div className="relative inline-block transform overflow-hidden rounded-2xl bg-white text-left align-bottom shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:align-middle border border-gray-100">
|
|
{/* Header */}
|
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 border-b border-gray-100 flex justify-between items-start">
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-brand-dark-blue" id="modal-title">Upload New Proof</h3>
|
|
<p className="text-sm text-gray-500 mt-1">Drag and drop your asset below to start AI analysis.</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-full p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
|
>
|
|
<XIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="px-4 pt-5 pb-4 sm:p-8">
|
|
<div className="space-y-6">
|
|
{/* Proof Name */}
|
|
<div>
|
|
<label htmlFor="proof-name" className="block text-sm font-bold text-brand-dark-blue mb-2">Proof Name</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<DocumentIcon className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<input
|
|
id="proof-name"
|
|
type="text"
|
|
value={proofName}
|
|
onChange={(e) => setProofName(e.target.value)}
|
|
placeholder="e.g., Q4 Hero Instagram Post"
|
|
disabled={isLoading}
|
|
className="w-full rounded-xl border-gray-300 shadow-sm focus:border-brand-accent focus:ring-brand-accent p-3 pl-10 bg-white transition-all text-gray-900"
|
|
required
|
|
/>
|
|
</div>
|
|
{isNewVersion && (
|
|
<p className="text-xs text-blue-600 mt-2 p-2 bg-blue-50 rounded-lg flex items-center gap-2">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-600"></span>
|
|
A proof with this name already exists. This will be uploaded as a new version.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dropdowns Grid */}
|
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
|
|
<div>
|
|
<label htmlFor="proof-channel" className="block text-sm font-bold text-brand-dark-blue mb-2">Channel</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<ChannelIcon className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<select
|
|
id="proof-channel"
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value)}
|
|
disabled={isLoading}
|
|
className="w-full rounded-xl border-gray-300 shadow-sm focus:border-brand-accent focus:ring-brand-accent p-3 pl-10 bg-white transition-all text-gray-900 appearance-none"
|
|
required
|
|
>
|
|
<option value="" disabled>Select Channel</option>
|
|
{availableChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400">
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="proof-sub-channel" className="block text-sm font-bold text-brand-dark-blue mb-2">Sub-Channel</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<TagIcon className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<select
|
|
id="proof-sub-channel"
|
|
value={subChannel}
|
|
onChange={(e) => setSubChannel(e.target.value)}
|
|
disabled={isLoading || !channel}
|
|
className="w-full rounded-xl border-gray-300 shadow-sm focus:border-brand-accent focus:ring-brand-accent p-3 pl-10 bg-white transition-all text-gray-900 appearance-none disabled:bg-gray-100 disabled:text-gray-400"
|
|
required
|
|
>
|
|
<option value="" disabled>Select Sub-Channel</option>
|
|
{availableSubChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400">
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<label htmlFor="proof-type" className="block text-sm font-bold text-brand-dark-blue mb-2">Proof Type</label>
|
|
<div className="relative">
|
|
<select
|
|
id="proof-type"
|
|
value={proofType}
|
|
onChange={(e) => setProofType(e.target.value)}
|
|
disabled={isLoading || !subChannel || availableProofTypes.length === 0}
|
|
className="w-full rounded-xl border-gray-300 shadow-sm focus:border-brand-accent focus:ring-brand-accent p-3 bg-white transition-all text-gray-900 appearance-none disabled:bg-gray-100 disabled:text-gray-400"
|
|
required={showProofType}
|
|
>
|
|
<option value="" disabled>
|
|
{!subChannel
|
|
? "Select a Sub-Channel first"
|
|
: availableProofTypes.length === 0
|
|
? "No proof types available"
|
|
: "Select Proof Type"
|
|
}
|
|
</option>
|
|
{availableProofTypes.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400">
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drag & Drop Zone */}
|
|
<div>
|
|
<label className="block text-sm font-bold text-brand-dark-blue mb-2">Proof File</label>
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
className={`
|
|
relative mt-1 flex justify-center px-6 pt-10 pb-10 border-2 border-dashed rounded-xl transition-all group cursor-pointer
|
|
${file ? 'border-emerald-500 bg-emerald-50' : 'border-gray-300 hover:bg-emerald-50/30 hover:border-emerald-400/50'}
|
|
`}
|
|
>
|
|
<input
|
|
id="file-upload"
|
|
name="file-upload"
|
|
type="file"
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
disabled={isLoading}
|
|
accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf"
|
|
/>
|
|
<div className="text-center space-y-2 pointer-events-none">
|
|
{!file ? (
|
|
<>
|
|
<div className="mx-auto h-16 w-16 bg-gray-100 text-gray-400 rounded-full flex items-center justify-center group-hover:scale-110 group-hover:text-emerald-600 group-hover:bg-emerald-100 transition-all duration-300">
|
|
<UploadIcon className="h-8 w-8" />
|
|
</div>
|
|
<div className="text-sm text-gray-600">
|
|
<span className="font-bold text-emerald-600">Click to upload</span> or drag and drop
|
|
</div>
|
|
<p className="text-xs text-gray-400">SVG, PNG, JPG or GIF (max. 800x400px)</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="mx-auto h-16 w-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center scale-110 shadow-sm">
|
|
<CheckCircleIcon className="h-8 w-8" />
|
|
</div>
|
|
<div className="text-sm font-bold text-brand-dark-blue truncate max-w-xs mx-auto">
|
|
{file.name}
|
|
</div>
|
|
<p className="text-xs text-gray-500">Ready for analysis</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="bg-gray-50 px-4 py-4 sm:px-6 border-t border-gray-100 flex flex-col-reverse sm:flex-row sm:justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isLoading}
|
|
className="w-full sm:w-auto bg-white border border-gray-300 text-gray-700 font-semibold py-2.5 px-5 rounded-xl hover:bg-gray-50 hover:text-gray-900 transition-colors duration-300 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitDisabled}
|
|
className="w-full sm:w-auto flex items-center justify-center bg-brand-accent text-white font-bold py-2.5 px-6 rounded-xl hover:bg-brand-dark-blue shadow-lg shadow-brand-accent/20 transition-all duration-300 disabled:bg-gray-300 disabled:text-gray-500 disabled:shadow-none disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<SpinnerIcon className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" />
|
|
Analyzing...
|
|
</>
|
|
) : 'Start Analysis'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const LoadingCell: React.FC<{ progress: { completed: number; total: number } }> = ({ progress }) => {
|
|
const percent = progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0;
|
|
|
|
let statusText = 'Analyzing...';
|
|
if (progress.completed < AGENT_NAMES.length) {
|
|
const agentName = AGENT_NAMES[progress.completed];
|
|
statusText = `Analyzing: ${agentName}...`;
|
|
} else if (progress.completed === AGENT_NAMES.length) {
|
|
statusText = 'Lead Agent Review...';
|
|
} else {
|
|
statusText = 'Finalizing...';
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full px-2 text-center w-full">
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5 mb-1.5">
|
|
<div
|
|
className="bg-brand-accent h-1.5 rounded-full"
|
|
style={{ width: `${percent}%`, transition: 'width 0.3s ease-in-out' }}
|
|
></div>
|
|
</div>
|
|
<span className="text-xs text-gray-600 truncate w-full">{statusText} ({percent}%)</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DeleteConfirmationModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
proofName: string;
|
|
}> = ({ isOpen, onClose, onConfirm, proofName }) => {
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
|
|
onClick={onClose}
|
|
aria-modal="true"
|
|
role="dialog"
|
|
>
|
|
<div
|
|
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-md transform transition-all"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-xl font-bold text-brand-dark-blue">Confirm Deletion</h3>
|
|
<p className="text-gray-600 my-4">
|
|
Are you sure you want to permanently delete the proof "{proofName}"? This action cannot be undone.
|
|
</p>
|
|
<div className="mt-6 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
className="bg-red-600 text-white font-semibold py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
|
>
|
|
Delete Proof
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CampaignDetail: React.FC<{
|
|
campaignName: string;
|
|
onBack: () => void;
|
|
onSelectProof: (proof: any) => void;
|
|
campaignProofs: { [key: string]: any[] };
|
|
onProofUpload: (file: File, proofName: string, channel: string, subChannel: string, proofType?: string) => void;
|
|
dropdownOptions: DropdownOptions;
|
|
onRetryAnalysis: (campaignName: string, tempId: string) => void;
|
|
onDeleteProof: (campaignName: string, proofName: string) => void;
|
|
}> = ({ campaignName, onBack, onSelectProof, campaignProofs, onProofUpload, dropdownOptions, onRetryAnalysis, onDeleteProof }) => {
|
|
const [isUploadFormVisible, setIsUploadFormVisible] = useState(false);
|
|
const [proofToDelete, setProofToDelete] = useState<any | null>(null);
|
|
const [proofForUpload, setProofForUpload] = useState<any | null>(null);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const proofs = campaignProofs[campaignName] || [];
|
|
const isUploading = proofs.some(proof => proof.status === 'analyzing');
|
|
const existingProofNames = proofs
|
|
.filter(proof => proof.status === 'completed')
|
|
.map(proof => proof.proofName);
|
|
|
|
const handleConfirmDelete = () => {
|
|
if (proofToDelete) {
|
|
onDeleteProof(campaignName, proofToDelete.proofName);
|
|
setProofToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleNewVersionClick = (e: React.MouseEvent, proof: any) => {
|
|
e.stopPropagation();
|
|
setProofForUpload(proof);
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file && proofForUpload) {
|
|
onProofUpload(
|
|
file,
|
|
proofForUpload.proofName,
|
|
proofForUpload.channel,
|
|
proofForUpload.subChannel,
|
|
proofForUpload.proofType
|
|
);
|
|
}
|
|
setProofForUpload(null);
|
|
if (event.target) {
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
const handleExportPDF = async (proofsToExport: any[], fileName: string) => {
|
|
setIsExporting(true);
|
|
|
|
const reportRootEl = document.createElement('div');
|
|
reportRootEl.style.position = 'absolute';
|
|
reportRootEl.style.left = '-9999px';
|
|
reportRootEl.style.top = '0px';
|
|
reportRootEl.style.zIndex = '-1';
|
|
document.body.appendChild(reportRootEl);
|
|
|
|
const reactRoot = ReactDOM.createRoot(reportRootEl);
|
|
|
|
try {
|
|
const proofsWithLatestVersion = proofsToExport.map(p => ({
|
|
...p,
|
|
versions: [p.versions[0]], // Only render the latest version
|
|
}));
|
|
|
|
reactRoot.render(<PDFReport campaignName={campaignName} proofs={proofsWithLatestVersion} />);
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for render and images
|
|
|
|
const { default: jspdf } = await import('jspdf');
|
|
const { default: html2canvas } = await import('html2canvas');
|
|
|
|
const reportContent = reportRootEl.children[0] as HTMLElement;
|
|
if (!reportContent) throw new Error("PDF report element not found");
|
|
|
|
const canvas = await html2canvas(reportContent, { scale: 2, useCORS: true });
|
|
|
|
const imgData = canvas.toDataURL('image/png');
|
|
const pdf = new jspdf('p', 'mm', 'a4', true);
|
|
const pdfWidth = pdf.internal.pageSize.getWidth();
|
|
const pdfHeight = pdf.internal.pageSize.getHeight();
|
|
const canvasWidth = canvas.width;
|
|
const canvasHeight = canvas.height;
|
|
const ratio = canvasWidth / pdfWidth;
|
|
const pagedCanvasHeight = canvasHeight / ratio;
|
|
|
|
let heightLeft = pagedCanvasHeight;
|
|
let position = 0;
|
|
|
|
pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pagedCanvasHeight, undefined, 'FAST');
|
|
heightLeft -= pdfHeight;
|
|
|
|
while (heightLeft > 0) {
|
|
position -= pdfHeight;
|
|
pdf.addPage();
|
|
pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pagedCanvasHeight, undefined, 'FAST');
|
|
heightLeft -= pdfHeight;
|
|
}
|
|
|
|
pdf.save(`${fileName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`);
|
|
|
|
} catch (error) {
|
|
console.error("Failed to generate PDF:", error);
|
|
alert("Sorry, there was an error creating the PDF. Please try again.");
|
|
} finally {
|
|
reactRoot.unmount();
|
|
document.body.removeChild(reportRootEl);
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
|
<DeleteConfirmationModal
|
|
isOpen={!!proofToDelete}
|
|
onClose={() => setProofToDelete(null)}
|
|
onConfirm={handleConfirmDelete}
|
|
proofName={proofToDelete?.proofName || ''}
|
|
/>
|
|
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf"
|
|
disabled={isUploading}
|
|
/>
|
|
|
|
<header className="mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="p-2 text-gray-500 rounded-full hover:bg-gray-200 hover:text-brand-dark-blue transition-colors duration-200"
|
|
title="Back to campaigns"
|
|
aria-label="Back to campaigns list"
|
|
>
|
|
<ArrowLeftIcon className="h-6 w-6" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">{campaignName}</h1>
|
|
<p className="text-base lg:text-lg text-gray-600 mt-1">Proof overview and compliance status.</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<UploadProofModal
|
|
isOpen={isUploadFormVisible}
|
|
onClose={() => setIsUploadFormVisible(false)}
|
|
dropdownOptions={dropdownOptions}
|
|
onSubmit={(file, proofName, channel, subChannel, proofType) => {
|
|
onProofUpload(file, proofName, channel, subChannel, proofType);
|
|
setIsUploadFormVisible(false);
|
|
}}
|
|
isLoading={isUploading}
|
|
existingProofNames={existingProofNames}
|
|
/>
|
|
|
|
<section>
|
|
<div className="mb-6 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => handleExportPDF(proofs.filter(p => p.status === 'completed'), `${campaignName} - Campaign Report`)}
|
|
disabled={isExporting || proofs.filter(p => p.status === 'completed').length === 0}
|
|
className="flex items-center gap-2 bg-white text-brand-dark-blue font-semibold py-2 px-4 rounded-lg border border-brand-dark-blue hover:bg-brand-gray transition-colors duration-300 disabled:bg-gray-300 disabled:text-gray-500 disabled:border-gray-300 disabled:cursor-wait"
|
|
>
|
|
{isExporting ? <SpinnerIcon className="h-5 w-5 custom-spinner" /> : <ExportIcon className="h-5 w-5" />}
|
|
{isExporting ? 'Exporting...' : 'Export Campaign Report'}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsUploadFormVisible(true)}
|
|
className="flex items-center gap-2 bg-brand-accent text-white font-semibold py-2 px-4 rounded-lg hover:bg-brand-dark-blue transition-colors duration-300"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Upload New Proof
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Workfront #</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Channel</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Sub-Channel</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Type</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Overall Status</th>
|
|
<th scope="col" className="relative px-6 py-3"><span className="sr-only">Actions</span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{proofs.map((proof, index) => {
|
|
if (proof.status === 'analyzing') {
|
|
return (
|
|
<tr key={proof.tempId} className="bg-gray-50 opacity-80">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{proof.proofName}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 italic">Pending</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.subChannel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.proofType || 'N/A'}</td>
|
|
<td className="px-6 py-4" colSpan={2}>
|
|
{proof.analysisProgress ?
|
|
<LoadingCell progress={proof.analysisProgress} /> :
|
|
<div className="flex justify-center items-center h-full">
|
|
<SpinnerIcon className="h-5 w-5 text-brand-accent custom-spinner" />
|
|
<span className="ml-2 text-sm text-gray-600">Preparing...</span>
|
|
</div>
|
|
}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
if (proof.status === 'error') {
|
|
return (
|
|
<tr key={proof.tempId} className="bg-red-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{proof.proofName}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 italic">Failed</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.subChannel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.proofType || 'N/A'}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-700 font-semibold" colSpan={2}>
|
|
<div className="flex items-center justify-between">
|
|
<span>Analysis failed.</span>
|
|
<button
|
|
onClick={() => onRetryAnalysis(campaignName, proof.tempId)}
|
|
className="flex items-center gap-1.5 text-xs font-semibold text-brand-accent hover:text-brand-dark-blue whitespace-nowrap px-3 py-1.5 rounded-full bg-brand-accent/10 hover:bg-brand-accent/20 transition-colors"
|
|
>
|
|
<ArrowPathIcon className="h-4 w-4" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
const isVersioned = proof.versions && proof.versions.length > 0;
|
|
const latestVersion = isVersioned ? proof.versions[0] : null;
|
|
const isClickable = isVersioned;
|
|
|
|
if (!latestVersion) return null; // Should not happen for completed proofs
|
|
|
|
return (
|
|
<tr
|
|
key={latestVersion.workfrontId || index}
|
|
className={isClickable ? "hover:bg-gray-100 cursor-pointer" : ""}
|
|
onClick={() => isClickable && onSelectProof(proof)}
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">
|
|
{proof.proofName}
|
|
{isVersioned && (
|
|
<span className="ml-2 bg-gray-200 text-gray-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
|
V{latestVersion.version}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{latestVersion.workfrontId}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.subChannel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{proof.proofType || 'N/A'}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
<OverallStatusBadge status={latestVersion.overallStatus} />
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={(e) => handleNewVersionClick(e, proof)}
|
|
className="p-2 text-gray-400 rounded-full hover:bg-blue-100 hover:text-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title={`Upload new version for ${proof.proofName}`}
|
|
disabled={isUploading || isExporting}
|
|
>
|
|
<UploadIcon className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleExportPDF([proof], `${campaignName} - ${proof.proofName} Report`);
|
|
}}
|
|
disabled={isExporting}
|
|
className="p-2 text-gray-400 rounded-full hover:bg-green-100 hover:text-green-600 transition-colors disabled:opacity-50 disabled:cursor-wait"
|
|
title={`Export PDF for ${proof.proofName}`}
|
|
>
|
|
<PDFIcon className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setProofToDelete(proof);
|
|
}}
|
|
disabled={isExporting}
|
|
className="p-2 text-gray-400 rounded-full hover:bg-red-100 hover:text-red-600 transition-colors disabled:opacity-50"
|
|
title={`Delete ${proof.proofName}`}
|
|
>
|
|
<TrashIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProofDetailView: React.FC<{
|
|
campaignName: string;
|
|
proof: any;
|
|
onBack: () => void;
|
|
onNewVersionUpload: (file: File) => void;
|
|
isUploadingNewVersion: boolean;
|
|
onFlagSubmit: (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
onResolveSubmit: (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
}> = ({ campaignName, proof, onBack, onNewVersionUpload, isUploadingNewVersion, onFlagSubmit, onResolveSubmit }) => {
|
|
|
|
const getInitialVersionIndex = () => {
|
|
if (proof.initialVersion && proof.versions) {
|
|
const index = proof.versions.findIndex((v: any) => v.version === proof.initialVersion);
|
|
return index > -1 ? index : 0;
|
|
}
|
|
return 0; // Default to the latest version (index 0)
|
|
};
|
|
|
|
const [selectedVersionIndex, setSelectedVersionIndex] = useState(getInitialVersionIndex);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleUploadClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
onNewVersionUpload(file);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// When proof versions change (e.g., new upload), reset to show the latest version.
|
|
setSelectedVersionIndex(0);
|
|
}, [proof.versions]);
|
|
|
|
const versions = proof.versions || [];
|
|
const selectedVersion = versions[selectedVersionIndex];
|
|
|
|
const handleFlagSubmitWrapper = (agentName: string, comments: string) => {
|
|
onFlagSubmit({
|
|
campaignName,
|
|
proofName: proof.proofName,
|
|
version: selectedVersion.version,
|
|
agentFlagged: agentName,
|
|
comments,
|
|
});
|
|
};
|
|
|
|
const handleResolveSubmitWrapper = (agentName: string, issueText: string, reason: string) => {
|
|
onResolveSubmit({
|
|
campaignName,
|
|
proofName: proof.proofName,
|
|
version: selectedVersion.version,
|
|
agent: agentName,
|
|
issue: issueText,
|
|
resolution: reason,
|
|
});
|
|
};
|
|
|
|
if (!selectedVersion) {
|
|
return (
|
|
<div className="p-8">
|
|
<p>Error: This proof has no versions to display.</p>
|
|
<button onClick={onBack}>Go Back</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
|
<header className="mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="p-2 text-gray-500 rounded-full hover:bg-gray-200 hover:text-brand-dark-blue transition-colors duration-200"
|
|
title="Back to campaign details"
|
|
aria-label="Back to campaign details"
|
|
>
|
|
<ArrowLeftIcon className="h-6 w-6" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">{proof.proofName}</h1>
|
|
<div className="flex items-center gap-2 mt-2 text-sm text-gray-500 font-medium">
|
|
<span>{proof.channel}</span>
|
|
<span className="text-gray-300">•</span>
|
|
<span>{proof.subChannel}</span>
|
|
{proof.proofType && (
|
|
<>
|
|
<span className="text-gray-300">•</span>
|
|
<span>{proof.proofType}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-screen-2xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-x-12">
|
|
<div className="lg:col-span-1">
|
|
<div className="sticky top-8 flex flex-col gap-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">
|
|
Proof Preview
|
|
</h2>
|
|
<ProofPreview
|
|
previewUrl={selectedVersion.proofPreviewUrl}
|
|
fileName={`${proof.proofName} - V${selectedVersion.version}`}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3 gap-2">
|
|
<h3 className="text-xl font-bold text-brand-dark-blue flex items-center gap-2">
|
|
<HistoryIcon className="h-6 w-6 text-brand-accent"/>
|
|
Version History
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => console.log('Download version clicked')}
|
|
className="flex items-center gap-2 text-sm bg-brand-accent text-white font-semibold py-1.5 px-3 rounded-lg border border-transparent hover:bg-brand-dark-blue transition-colors duration-200"
|
|
title={`Download Version ${selectedVersion.version}`}
|
|
>
|
|
<DownloadIcon className="h-4 w-4" />
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={handleUploadClick}
|
|
disabled={isUploadingNewVersion}
|
|
className="flex items-center gap-2 text-sm bg-white text-brand-accent font-semibold py-1.5 px-3 rounded-lg border border-brand-accent hover:bg-brand-accent/10 transition-colors duration-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-wait"
|
|
title="Upload a new version of this proof"
|
|
>
|
|
{isUploadingNewVersion ? (
|
|
<>
|
|
<SpinnerIcon className="h-4 w-4 custom-spinner" />
|
|
Uploading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<UploadIcon className="h-4 w-4" />
|
|
New Version
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf"
|
|
disabled={isUploadingNewVersion}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{versions.map((version: any, index: number) => {
|
|
const isActive = index === selectedVersionIndex;
|
|
return (
|
|
<button
|
|
key={version.version}
|
|
onClick={() => setSelectedVersionIndex(index)}
|
|
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${
|
|
isActive
|
|
? 'bg-brand-accent/10 border-brand-accent shadow-sm'
|
|
: 'bg-white border-gray-200 hover:border-brand-accent/50 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<p className={`font-bold ${isActive ? 'text-brand-dark-blue' : 'text-gray-800'}`}>Version {version.version}</p>
|
|
<p className="text-xs text-gray-500">{version.timestamp}</p>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-1">Workfront ID: {version.workfrontId}</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-12 lg:mt-0 lg:col-span-2">
|
|
<FeedbackReport
|
|
feedback={selectedVersion.feedback}
|
|
onFlagSubmit={handleFlagSubmitWrapper}
|
|
onResolveSubmit={handleResolveSubmitWrapper}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
|
|
interface CampaignsProps {
|
|
selectedCampaign: string | null;
|
|
selectedProof: any | null;
|
|
onSelectCampaign: (campaignName: string) => void;
|
|
onSelectProof: (proof: any) => void;
|
|
onBackToCampaignsList: () => void;
|
|
onBackToCampaignDetails: () => void;
|
|
campaigns: typeof initialCampaigns;
|
|
campaignProofs: typeof initialCampaignProofs;
|
|
onAddNewCampaign: (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => void;
|
|
onProofUpload: (campaignName: string, file: File, proofName: string, channel: string, subChannel: string, proofType?: string) => void;
|
|
dropdownOptions: DropdownOptions;
|
|
onRetryAnalysis: (campaignName: string, tempId: string) => void;
|
|
onCampaignStatusChange: (campaignName: string, newStatus: 'In Progress' | 'Completed') => void;
|
|
onDeleteProof: (campaignName: string, proofName: string) => void;
|
|
onFlagSubmit: (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
onResolveSubmit: (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
}
|
|
|
|
export const Campaigns: React.FC<CampaignsProps> = ({
|
|
selectedCampaign,
|
|
selectedProof,
|
|
onSelectCampaign,
|
|
onSelectProof,
|
|
onBackToCampaignsList,
|
|
onBackToCampaignDetails,
|
|
campaigns,
|
|
campaignProofs,
|
|
onAddNewCampaign,
|
|
onProofUpload,
|
|
dropdownOptions,
|
|
onRetryAnalysis,
|
|
onCampaignStatusChange,
|
|
onDeleteProof,
|
|
onFlagSubmit,
|
|
onResolveSubmit,
|
|
}) => {
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
if (selectedCampaign && selectedProof) {
|
|
const handleNewVersionUpload = (file: File) => {
|
|
onProofUpload(
|
|
selectedCampaign,
|
|
file,
|
|
selectedProof.proofName,
|
|
selectedProof.channel,
|
|
selectedProof.subChannel,
|
|
selectedProof.proofType
|
|
);
|
|
};
|
|
|
|
const isUploadingNewVersion = campaignProofs[selectedCampaign]?.some(
|
|
proof => (proof.status === 'analyzing' || proof.status === 'loading') && proof.proofName === selectedProof.proofName
|
|
);
|
|
|
|
return <ProofDetailView
|
|
campaignName={selectedCampaign}
|
|
proof={selectedProof}
|
|
onBack={onBackToCampaignDetails}
|
|
onNewVersionUpload={handleNewVersionUpload}
|
|
isUploadingNewVersion={isUploadingNewVersion}
|
|
onFlagSubmit={onFlagSubmit}
|
|
onResolveSubmit={onResolveSubmit}
|
|
/>;
|
|
}
|
|
|
|
if (selectedCampaign) {
|
|
return <CampaignDetail
|
|
campaignName={selectedCampaign}
|
|
onBack={onBackToCampaignsList}
|
|
onSelectProof={onSelectProof}
|
|
campaignProofs={campaignProofs}
|
|
onProofUpload={(file, proofName, channel, subChannel, proofType) => onProofUpload(selectedCampaign, file, proofName, channel, subChannel, proofType)}
|
|
dropdownOptions={dropdownOptions}
|
|
onRetryAnalysis={onRetryAnalysis}
|
|
onDeleteProof={onDeleteProof}
|
|
/>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<CreateCampaignModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onAddCampaign={onAddNewCampaign}
|
|
/>
|
|
<CampaignList
|
|
onSelectCampaign={onSelectCampaign}
|
|
campaigns={campaigns}
|
|
onOpenModal={() => setIsModalOpen(true)}
|
|
onCampaignStatusChange={onCampaignStatusChange}
|
|
/>
|
|
</>
|
|
);
|
|
};
|