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>
1133 lines
60 KiB
TypeScript
Executable file
1133 lines
60 KiB
TypeScript
Executable file
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { PlusIcon } from './icons/PlusIcon';
|
|
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
|
|
import type { AgentReview, FlaggedItem, ResolvedItem } from '../types';
|
|
import { FeedbackReport } from './FeedbackReport';
|
|
import { CreateProjectModal } from './CreateProjectModal';
|
|
import { CheckCircleIcon, ArrowPathIcon, ExclamationTriangleIcon } from './icons/StatusIcons';
|
|
import { AssetPreview } from './AssetPreview';
|
|
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';
|
|
|
|
|
|
export const initialProjects = [
|
|
{
|
|
name: 'Barclays Q4 campaign',
|
|
workfrontId: '#WF_12822',
|
|
clientLead: 'Jane Doe',
|
|
agency: 'OLIVER Agency',
|
|
agencyLead: 'Steve O\'Donoghue',
|
|
assets: 1,
|
|
status: 'In Progress',
|
|
lastModified: '2024-07-22',
|
|
},
|
|
{
|
|
name: 'Barclays Q3 Roundup',
|
|
workfrontId: '#WF_12750',
|
|
clientLead: 'Jane Doe',
|
|
agency: 'OLIVER Agency',
|
|
agencyLead: 'Steve O\'Donoghue',
|
|
assets: 1,
|
|
status: 'Completed',
|
|
lastModified: '2024-06-30',
|
|
},
|
|
];
|
|
|
|
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 analyse 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 analyse this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.",
|
|
issues: []
|
|
},
|
|
channelBestPracticesAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "The content strategy is clear and effectively communicates the partnership message. The call-to-action is prominent.",
|
|
issues: []
|
|
},
|
|
channelTechSpecsAgentReview: {
|
|
ragStatus: "Amber",
|
|
feedback: "While the image quality is high, the composition is not optimised 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: []
|
|
},
|
|
channelBestPracticesAgentReview: {
|
|
ragStatus: "Green",
|
|
feedback: "The content strategy is effective with clear messaging and well-optimised visual hierarchy. No issues found.",
|
|
issues: []
|
|
},
|
|
channelTechSpecsAgentReview: {
|
|
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 initialProjectAssets: { [key: string]: any[] } = {
|
|
'Barclays Q4 campaign': [
|
|
{
|
|
assetName: 'IG Hero Post 1',
|
|
channel: 'Social',
|
|
subChannel: 'Meta',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 2,
|
|
timestamp: '2024-07-25',
|
|
workfrontId: '#WF_12823-V2',
|
|
assetPreviewUrl: 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',
|
|
assetPreviewUrl: IG_HERO_POST_1_IMAGE_V1,
|
|
feedback: IG_HERO_POST_1_FEEDBACK_V1,
|
|
overallStatus: 'Analysis Error',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
'Barclays Q3 Roundup': [
|
|
{
|
|
assetName: 'Q3 Results Infographic',
|
|
channel: 'Display',
|
|
subChannel: 'Banner',
|
|
status: 'completed',
|
|
overallStatus: 'Passed',
|
|
versions: [
|
|
{
|
|
version: 1,
|
|
timestamp: '2024-06-28',
|
|
workfrontId: '#WF_12751-V1',
|
|
assetPreviewUrl: '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: [] },
|
|
channelBestPracticesAgentReview: { ragStatus: "Green", feedback: "The content strategy is effective and well-optimised for engagement.", issues: [] },
|
|
channelTechSpecsAgentReview: { 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-warning text-oliver-black';
|
|
break;
|
|
case 'Completed':
|
|
colorClasses = 'bg-success text-white';
|
|
break;
|
|
case 'Needs Review':
|
|
colorClasses = 'bg-warning-light text-oliver-black';
|
|
break;
|
|
default:
|
|
colorClasses = 'bg-oliver-grey text-oliver-black';
|
|
}
|
|
return (
|
|
<span className={`px-2.5 py-0.5 text-xs font-semibold rounded-full ${colorClasses}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const OverallStatusBadge: React.FC<{ status: 'Passed' | 'Failed' | 'Analysis Error' }> = ({ status }) => {
|
|
let colorClasses = '';
|
|
switch (status) {
|
|
case 'Passed':
|
|
colorClasses = 'bg-success text-white';
|
|
break;
|
|
case 'Failed':
|
|
colorClasses = 'bg-error text-white';
|
|
break;
|
|
case 'Analysis Error':
|
|
colorClasses = 'bg-grey-300 text-oliver-black';
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<span className={`inline-flex items-center px-3 py-1 text-sm font-bold rounded-full ${colorClasses}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
|
|
const ProjectList: React.FC<{
|
|
onSelectProject: (name: string) => void;
|
|
projects: typeof initialProjects;
|
|
projectAssets: typeof initialProjectAssets;
|
|
onOpenModal: () => void;
|
|
onProjectStatusChange: (projectName: string, newStatus: 'In Progress' | 'Completed') => void;
|
|
}> = ({ onSelectProject, projects, onOpenModal, onProjectStatusChange }) => {
|
|
const [showCompleted, setShowCompleted] = useState(true);
|
|
|
|
const filteredProjects = showCompleted ? projects : projects.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-oliver-azure appearance-none cursor-pointer';
|
|
let colorClasses = '';
|
|
switch (status) {
|
|
case 'In Progress':
|
|
colorClasses = 'bg-warning text-oliver-black';
|
|
break;
|
|
case 'Completed':
|
|
colorClasses = 'bg-success text-white';
|
|
break;
|
|
default:
|
|
colorClasses = 'bg-oliver-grey text-oliver-black';
|
|
}
|
|
return `${baseClasses} ${colorClasses}`;
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-oliver-grey">
|
|
<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-oliver-black">Projects</h1>
|
|
<p className="text-base lg:text-lg text-oliver-black 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-oliver-black whitespace-nowrap">Show Completed</label>
|
|
<ToggleSwitch enabled={showCompleted} onChange={setShowCompleted} />
|
|
</div>
|
|
<button
|
|
onClick={onOpenModal}
|
|
className="flex items-center gap-2 bg-oliver-azure text-white font-semibold py-2 px-4 rounded-full hover:bg-oliver-azure transition-colors duration-300"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Create New Project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section>
|
|
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-grey-300">
|
|
<thead className="bg-oliver-sky">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Project Name</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Proofs</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Status</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Created By</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Owning Agency</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Last Modified</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-grey-300">
|
|
{filteredProjects.map((project) => {
|
|
return (
|
|
<tr key={project.name} className="hover:bg-oliver-grey cursor-pointer even:bg-oliver-grey" onClick={() => onSelectProject(project.name)}>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-oliver-black">{project.name}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{project.assets}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm w-40">
|
|
<div className="relative">
|
|
<select
|
|
value={project.status}
|
|
onChange={(e) => onProjectStatusChange(project.name, e.target.value as 'In Progress' | 'Completed')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={getStatusSelectClasses(project.status)}
|
|
aria-label={`Change status for ${project.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-oliver-black" />
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{project.agencyLead}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{project.agency}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{project.lastModified}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProjectAssetUpload: React.FC<{
|
|
dropdownOptions: DropdownOptions;
|
|
onSubmit: (file: File, assetName: string, channel: string, subChannel: string) => void;
|
|
onCancel: () => void;
|
|
isLoading: boolean;
|
|
existingAssetNames: string[];
|
|
}> = ({ dropdownOptions, onSubmit, onCancel, isLoading, existingAssetNames }) => {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [assetName, setAssetName] = useState('');
|
|
const [channel, setChannel] = useState('');
|
|
const [subChannel, setSubChannel] = useState('');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const availableChannels = Object.keys(dropdownOptions.channels);
|
|
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
|
|
|
|
// Reset dependents
|
|
useEffect(() => {
|
|
setSubChannel('');
|
|
}, [channel]);
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFile = event.target.files?.[0];
|
|
if (selectedFile) {
|
|
setFile(selectedFile);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (file && assetName && channel && subChannel) {
|
|
onSubmit(file, assetName, channel, subChannel);
|
|
setFile(null);
|
|
setAssetName('');
|
|
setChannel('');
|
|
setSubChannel('');
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const isSubmitDisabled = !assetName || !file || !channel || !subChannel || isLoading;
|
|
const isNewVersion = existingAssetNames.includes(assetName.trim()) && assetName.trim() !== '';
|
|
|
|
return (
|
|
<section className="bg-white rounded-[10px] shadow-md p-6 border border-grey-300 mb-8">
|
|
<h2 className="text-xl font-bold text-oliver-black mb-4">Upload New Proof</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="asset-name" className="flex items-center text-sm font-medium text-oliver-black mb-1">
|
|
<DocumentIcon className="h-5 w-5 mr-2 text-oliver-azure shrink-0" />
|
|
Proof Name
|
|
</label>
|
|
<input
|
|
id="asset-name"
|
|
type="text"
|
|
value={assetName}
|
|
onChange={(e) => setAssetName(e.target.value)}
|
|
placeholder="e.g., Q4 Hero Instagram Post"
|
|
disabled={isLoading}
|
|
className="w-full bg-white border border-grey-300 rounded-[10px] py-2 px-3 text-oliver-black focus:outline-none focus:ring-2 focus:ring-oliver-azure disabled:bg-oliver-grey"
|
|
required
|
|
/>
|
|
{isNewVersion && (
|
|
<p className="text-xs text-oliver-azure mt-2 p-2 bg-oliver-grey rounded-[10px]">
|
|
A proof with this name already exists. This will be uploaded as a new version.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* Channel Dropdown */}
|
|
<div>
|
|
<label htmlFor="asset-channel" className="flex items-center text-sm font-medium text-oliver-black mb-1">
|
|
<ChannelIcon className="h-5 w-5 mr-2 text-oliver-azure shrink-0" />
|
|
Channel
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
id="asset-channel"
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value)}
|
|
disabled={isLoading}
|
|
className="w-full bg-white border border-grey-300 rounded-[10px] py-2 pl-3 pr-10 text-oliver-black focus:outline-none focus:ring-2 focus:ring-oliver-azure appearance-none disabled:bg-oliver-grey"
|
|
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-oliver-black/60">
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Sub-Channel Dropdown */}
|
|
<div>
|
|
<label htmlFor="asset-sub-channel" className="flex items-center text-sm font-medium text-oliver-black mb-1">
|
|
<TagIcon className="h-5 w-5 mr-2 text-oliver-azure shrink-0" />
|
|
Sub-Channel
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
id="asset-sub-channel"
|
|
value={subChannel}
|
|
onChange={(e) => setSubChannel(e.target.value)}
|
|
disabled={isLoading || !channel}
|
|
className="w-full bg-white border border-grey-300 rounded-[10px] py-2 pl-3 pr-10 text-oliver-black focus:outline-none focus:ring-2 focus:ring-oliver-azure appearance-none disabled:bg-oliver-grey disabled:text-oliver-black/60"
|
|
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-oliver-black/60">
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-oliver-black mb-1">Proof File</label>
|
|
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-grey-300 border-dashed rounded-[10px]">
|
|
<div className="space-y-1 text-center">
|
|
<UploadIcon className="mx-auto h-12 w-12 text-oliver-black/60" />
|
|
<div className="flex text-sm text-oliver-black">
|
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-[10px] font-medium text-oliver-azure hover:text-oliver-black focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-oliver-azure">
|
|
<span>Upload a file</span>
|
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" ref={fileInputRef} onChange={handleFileChange} disabled={isLoading} accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf" />
|
|
</label>
|
|
<p className="pl-1">or drag and drop</p>
|
|
</div>
|
|
{file ? <p className="text-sm font-semibold text-oliver-black mt-2">{file.name}</p> : <p className="text-xs text-oliver-black">PNG, JPG, GIF, WEBP, MP4, PDF</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2 gap-3">
|
|
<button type="button" onClick={onCancel} disabled={isLoading} className="bg-grey-300 text-oliver-black font-semibold py-2.5 px-5 rounded-full hover:bg-gray-500 hover:text-white transition-colors duration-300 disabled:opacity-50">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitDisabled} className="flex items-center justify-center bg-oliver-azure text-white font-bold py-2.5 px-5 rounded-full hover:bg-oliver-azure transition-all duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed">
|
|
{isLoading ? (
|
|
<>
|
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
Analysing...
|
|
</>
|
|
) : 'Upload & Analyse Proof'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
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 = 'Analysing...';
|
|
if (progress.completed < AGENT_NAMES.length) {
|
|
const agentName = AGENT_NAMES[progress.completed];
|
|
statusText = `Analysing: ${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-grey-300 rounded-full h-1.5 mb-1.5">
|
|
<div
|
|
className="bg-oliver-azure h-1.5 rounded-full"
|
|
style={{ width: `${percent}%`, transition: 'width 0.3s ease-in-out' }}
|
|
></div>
|
|
</div>
|
|
<span className="text-xs text-oliver-black truncate w-full">{statusText} ({percent}%)</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AnalysisErrorModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
assetName: string;
|
|
feedback: AgentReview | null;
|
|
}> = ({ isOpen, onClose, assetName, feedback }) => {
|
|
if (!isOpen || !feedback) return null;
|
|
|
|
const agentEntries: { label: string; review: { ragStatus: string; feedback: string } }[] = [
|
|
{ label: 'Risk & Control Agent', review: feedback.legalAgentReview },
|
|
{ label: 'Brand Agent', review: feedback.brandAgentReview },
|
|
{ label: 'Channel Best Practices Agent', review: feedback.channelBestPracticesAgentReview },
|
|
{ label: 'Channel Tech Specs Agent', review: feedback.channelTechSpecsAgentReview },
|
|
];
|
|
|
|
const failedAgents = agentEntries.filter(a => a.review.ragStatus === 'Error');
|
|
|
|
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-[10px] shadow-xl p-6 sm:p-8 w-full max-w-lg max-h-[80vh] overflow-y-auto transform transition-all border-2 border-oliver-azure"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<div className="flex-shrink-0 p-2 bg-red-100 rounded-full">
|
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-bold text-oliver-black">Analysis Error</h3>
|
|
<p className="text-sm text-oliver-black/60 mt-0.5">{assetName}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{feedback.leadAgentSummary && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
|
<h4 className="text-sm font-semibold text-red-800 mb-1">Summary</h4>
|
|
<p className="text-sm text-red-700">{feedback.leadAgentSummary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{failedAgents.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-oliver-black/60">Agent Details</h4>
|
|
{failedAgents.map(agent => (
|
|
<div key={agent.label} className="bg-oliver-grey rounded-lg p-3">
|
|
<p className="text-sm font-medium text-oliver-black">{agent.label}</p>
|
|
<p className="text-sm text-oliver-black/60 mt-1">{agent.review.feedback}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="border-2 border-oliver-azure text-oliver-azure font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure hover:text-white transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DeleteConfirmationModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
assetName: string;
|
|
}> = ({ isOpen, onClose, onConfirm, assetName }) => {
|
|
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-[10px] shadow-xl p-6 sm:p-8 w-full max-w-md transform transition-all border-2 border-oliver-azure"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-xl font-bold text-oliver-black">Confirm Deletion</h3>
|
|
<p className="text-oliver-black my-4">
|
|
Are you sure you want to permanently delete the proof "{assetName}"? This action cannot be undone.
|
|
</p>
|
|
<div className="mt-6 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="bg-grey-300 text-oliver-black font-semibold py-2 px-4 rounded-full hover:bg-gray-500 hover:text-white transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
className="bg-error text-white font-semibold py-2 px-4 rounded-full hover:bg-error/80 transition-colors"
|
|
>
|
|
Delete Proof
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProjectDetail: React.FC<{
|
|
projectName: string;
|
|
onBack: () => void;
|
|
onSelectAsset: (asset: any) => void;
|
|
projectAssets: { [key: string]: any[] };
|
|
onAssetUpload: (file: File, assetName: string, channel: string, subChannel: string) => void;
|
|
dropdownOptions: DropdownOptions;
|
|
onRetryAnalysis: (projectName: string, tempId: string) => void;
|
|
onDeleteAsset: (projectName: string, assetName: string) => void;
|
|
}> = ({ projectName, onBack, onSelectAsset, projectAssets, onAssetUpload, dropdownOptions, onRetryAnalysis, onDeleteAsset }) => {
|
|
const [isUploadFormVisible, setIsUploadFormVisible] = useState(false);
|
|
const [assetToDelete, setAssetToDelete] = useState<any | null>(null);
|
|
const [assetForUpload, setAssetForUpload] = useState<any | null>(null);
|
|
const [errorAsset, setErrorAsset] = useState<any | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const assets = projectAssets[projectName] || [];
|
|
const isUploading = assets.some(asset => asset.status === 'analyzing');
|
|
const existingAssetNames = assets
|
|
.filter(asset => asset.status === 'completed')
|
|
.map(asset => asset.assetName);
|
|
|
|
const handleConfirmDelete = () => {
|
|
if (assetToDelete) {
|
|
onDeleteAsset(projectName, assetToDelete.assetName);
|
|
setAssetToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleNewVersionClick = (e: React.MouseEvent, asset: any) => {
|
|
e.stopPropagation();
|
|
setAssetForUpload(asset);
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file && assetForUpload) {
|
|
onAssetUpload(
|
|
file,
|
|
assetForUpload.assetName,
|
|
assetForUpload.channel,
|
|
assetForUpload.subChannel
|
|
);
|
|
}
|
|
setAssetForUpload(null);
|
|
if (event.target) {
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-oliver-grey">
|
|
<DeleteConfirmationModal
|
|
isOpen={!!assetToDelete}
|
|
onClose={() => setAssetToDelete(null)}
|
|
onConfirm={handleConfirmDelete}
|
|
assetName={assetToDelete?.assetName || ''}
|
|
/>
|
|
|
|
<AnalysisErrorModal
|
|
isOpen={!!errorAsset}
|
|
onClose={() => setErrorAsset(null)}
|
|
assetName={errorAsset?.assetName || ''}
|
|
feedback={errorAsset?.versions?.[0]?.feedback || null}
|
|
/>
|
|
|
|
<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-oliver-black/60 rounded-full hover:bg-grey-300 hover:text-oliver-black transition-colors duration-200"
|
|
title="Back to projects"
|
|
aria-label="Back to projects list"
|
|
>
|
|
<ArrowLeftIcon className="h-6 w-6" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-3xl lg:text-4xl font-bold text-oliver-black">{projectName}</h1>
|
|
<p className="text-base lg:text-lg text-oliver-black mt-1">Proof overview and compliance status.</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{isUploadFormVisible ? (
|
|
<ProjectAssetUpload
|
|
dropdownOptions={dropdownOptions}
|
|
onSubmit={(file, assetName, channel, subChannel) => {
|
|
onAssetUpload(file, assetName, channel, subChannel);
|
|
setIsUploadFormVisible(false);
|
|
}}
|
|
onCancel={() => setIsUploadFormVisible(false)}
|
|
isLoading={isUploading}
|
|
existingAssetNames={existingAssetNames}
|
|
/>
|
|
) : null}
|
|
|
|
<section>
|
|
{!isUploadFormVisible && (
|
|
<div className="mb-6 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => console.log('Download all proofs clicked')}
|
|
className="flex items-center gap-2 bg-white text-oliver-azure font-semibold py-2 px-4 rounded-full border-2 border-oliver-azure hover:bg-oliver-azure/10 transition-colors duration-300"
|
|
>
|
|
<DownloadIcon className="h-5 w-5" />
|
|
Download All Proofs
|
|
</button>
|
|
<button
|
|
onClick={() => setIsUploadFormVisible(true)}
|
|
className="flex items-center gap-2 bg-oliver-azure text-white font-semibold py-2 px-4 rounded-full hover:bg-oliver-azure transition-colors duration-300"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Upload New Proof
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-grey-300">
|
|
<thead className="bg-oliver-sky">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Proof Name</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Workfront #</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Channel</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Sub-Channel</th>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black 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-grey-300">
|
|
{assets.map((asset, index) => {
|
|
if (asset.status === 'analyzing') {
|
|
return (
|
|
<tr key={asset.tempId} className="bg-oliver-grey opacity-80">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-oliver-black">{asset.assetName}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black/60 italic">Pending</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.subChannel}</td>
|
|
<td className="px-6 py-4" colSpan={2}>
|
|
{asset.analysisProgress ?
|
|
<LoadingCell progress={asset.analysisProgress} /> :
|
|
<div className="flex justify-center items-center h-full">
|
|
<SpinnerIcon className="h-5 w-5 text-oliver-azure custom-spinner" />
|
|
<span className="ml-2 text-sm text-oliver-black">Preparing...</span>
|
|
</div>
|
|
}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
if (asset.status === 'error') {
|
|
return (
|
|
<tr key={asset.tempId} className="bg-error-light">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-oliver-black">{asset.assetName}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black/60 italic">Failed</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.subChannel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-error font-semibold" colSpan={2}>
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => setErrorAsset(asset)}
|
|
className="text-error hover:text-red-800 underline underline-offset-2 cursor-pointer transition-colors"
|
|
>
|
|
Analysis failed.
|
|
</button>
|
|
<button
|
|
onClick={() => onRetryAnalysis(projectName, asset.tempId || asset._id)}
|
|
className="flex items-center gap-1.5 text-xs font-semibold text-oliver-azure hover:text-oliver-black whitespace-nowrap px-3 py-1.5 rounded-full bg-oliver-azure/10 hover:bg-oliver-azure/20 transition-colors"
|
|
>
|
|
<ArrowPathIcon className="h-4 w-4" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
const isVersioned = asset.versions && asset.versions.length > 0;
|
|
const latestVersion = isVersioned ? asset.versions[0] : null;
|
|
const isClickable = isVersioned;
|
|
|
|
if (!latestVersion) return null; // Should not happen for completed assets
|
|
|
|
return (
|
|
<tr
|
|
key={latestVersion.workfrontId || index}
|
|
className={isClickable ? "hover:bg-oliver-grey cursor-pointer even:bg-oliver-grey" : "even:bg-oliver-grey"}
|
|
onClick={() => isClickable && onSelectAsset(asset)}
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-oliver-black">
|
|
{asset.assetName}
|
|
{isVersioned && (
|
|
<span className="ml-2 bg-grey-300 text-oliver-black 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-oliver-black">{latestVersion.workfrontId}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.channel}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">{asset.subChannel}</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, asset)}
|
|
className="p-2 text-oliver-black/60 rounded-full hover:bg-oliver-grey hover:text-oliver-azure transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title={`Upload new version of ${asset.assetName}`}
|
|
disabled={isUploading}
|
|
>
|
|
<UploadIcon className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); console.log('Download proof clicked'); }}
|
|
className="p-2 text-oliver-black/60 rounded-full hover:bg-success-light hover:text-success transition-colors"
|
|
title={`Download ${asset.assetName}`}
|
|
>
|
|
<DownloadIcon className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setAssetToDelete(asset);
|
|
}}
|
|
className="p-2 text-oliver-black/60 rounded-full hover:bg-error-light hover:text-error transition-colors"
|
|
title={`Delete ${asset.assetName}`}
|
|
>
|
|
<TrashIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AssetDetailView: React.FC<{
|
|
projectName: string;
|
|
asset: 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;
|
|
}> = ({ projectName, asset, onBack, onNewVersionUpload, isUploadingNewVersion, onFlagSubmit, onResolveSubmit }) => {
|
|
|
|
const getInitialVersionIndex = () => {
|
|
if (asset.initialVersion && asset.versions) {
|
|
const index = asset.versions.findIndex((v: any) => v.version === asset.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 asset versions change (e.g., new upload), reset to show the latest version.
|
|
setSelectedVersionIndex(0);
|
|
}, [asset.versions]);
|
|
|
|
const versions = asset.versions || [];
|
|
const selectedVersion = versions[selectedVersionIndex];
|
|
|
|
const handleFlagSubmitWrapper = (agentName: string, comments: string) => {
|
|
onFlagSubmit({
|
|
projectName,
|
|
assetName: asset.assetName,
|
|
version: selectedVersion.version,
|
|
agentFlagged: agentName,
|
|
comments,
|
|
});
|
|
};
|
|
|
|
const handleResolveSubmitWrapper = (agentName: string, issueText: string, reason: string) => {
|
|
onResolveSubmit({
|
|
projectName,
|
|
assetName: asset.assetName,
|
|
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-oliver-grey">
|
|
<header className="mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="p-2 text-oliver-black/60 rounded-full hover:bg-grey-300 hover:text-oliver-black transition-colors duration-200"
|
|
title="Back to project details"
|
|
aria-label="Back to project details"
|
|
>
|
|
<ArrowLeftIcon className="h-6 w-6" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-3xl lg:text-4xl font-bold text-oliver-black">{asset.assetName}</h1>
|
|
<p className="text-base lg:text-lg text-oliver-black mt-1">Detailed AI feedback report.</p>
|
|
</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-oliver-black mb-4">
|
|
Proof Preview
|
|
</h2>
|
|
<AssetPreview
|
|
previewUrl={selectedVersion.assetPreviewUrl}
|
|
fileName={`${asset.assetName} - V${selectedVersion.version}`}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3 gap-2">
|
|
<h3 className="text-xl font-bold text-oliver-black flex items-center gap-2">
|
|
<HistoryIcon className="h-6 w-6 text-oliver-azure"/>
|
|
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-oliver-azure text-white font-semibold py-1.5 px-3 rounded-full border border-transparent hover:bg-oliver-azure 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-oliver-azure font-semibold py-1.5 px-3 rounded-full border-2 border-oliver-azure hover:bg-oliver-azure/10 transition-colors duration-200 disabled:bg-gray-300 disabled:text-oliver-black/60 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-[10px] border-2 transition-all ${
|
|
isActive
|
|
? 'bg-oliver-grey border-oliver-azure shadow-sm'
|
|
: 'bg-white border-grey-300 hover:border-oliver-azure/50 hover:bg-oliver-grey'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<p className={`font-bold ${isActive ? 'text-oliver-black' : 'text-oliver-black'}`}>Version {version.version}</p>
|
|
<p className="text-xs text-oliver-black/60">{version.timestamp}</p>
|
|
</div>
|
|
<p className="text-sm text-oliver-black 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 ProjectsProps {
|
|
selectedProject: string | null;
|
|
selectedAsset: any | null;
|
|
onSelectProject: (projectName: string) => void;
|
|
onSelectAsset: (asset: any) => void;
|
|
onBackToProjectsList: () => void;
|
|
onBackToProjectDetails: () => void;
|
|
projects: typeof initialProjects;
|
|
projectAssets: typeof initialProjectAssets;
|
|
onAddNewProject: (projectData: { name: string; workfrontId: string; clientLead: string; }) => void;
|
|
onAssetUpload: (projectName: string, file: File, assetName: string, channel: string, subChannel: string) => void;
|
|
dropdownOptions: DropdownOptions;
|
|
onRetryAnalysis: (projectName: string, tempId: string) => void;
|
|
onProjectStatusChange: (projectName: string, newStatus: 'In Progress' | 'Completed') => void;
|
|
onDeleteAsset: (projectName: string, assetName: string) => void;
|
|
onFlagSubmit: (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
onResolveSubmit: (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
|
|
}
|
|
|
|
export const Projects: React.FC<ProjectsProps> = ({
|
|
selectedProject,
|
|
selectedAsset,
|
|
onSelectProject,
|
|
onSelectAsset,
|
|
onBackToProjectsList,
|
|
onBackToProjectDetails,
|
|
projects,
|
|
projectAssets,
|
|
onAddNewProject,
|
|
onAssetUpload,
|
|
dropdownOptions,
|
|
onRetryAnalysis,
|
|
onProjectStatusChange,
|
|
onDeleteAsset,
|
|
onFlagSubmit,
|
|
onResolveSubmit,
|
|
}) => {
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
if (selectedProject && selectedAsset) {
|
|
const handleNewVersionUpload = (file: File) => {
|
|
onAssetUpload(
|
|
selectedProject,
|
|
file,
|
|
selectedAsset.assetName,
|
|
selectedAsset.channel,
|
|
selectedAsset.subChannel
|
|
);
|
|
};
|
|
|
|
const isUploadingNewVersion = projectAssets[selectedProject]?.some(
|
|
asset => (asset.status === 'analyzing' || asset.status === 'loading') && asset.assetName === selectedAsset.assetName
|
|
);
|
|
|
|
return <AssetDetailView
|
|
projectName={selectedProject}
|
|
asset={selectedAsset}
|
|
onBack={onBackToProjectDetails}
|
|
onNewVersionUpload={handleNewVersionUpload}
|
|
isUploadingNewVersion={isUploadingNewVersion}
|
|
onFlagSubmit={onFlagSubmit}
|
|
onResolveSubmit={onResolveSubmit}
|
|
/>;
|
|
}
|
|
|
|
if (selectedProject) {
|
|
return <ProjectDetail
|
|
projectName={selectedProject}
|
|
onBack={onBackToProjectsList}
|
|
onSelectAsset={onSelectAsset}
|
|
projectAssets={projectAssets}
|
|
onAssetUpload={(file, assetName, channel, subChannel) => onAssetUpload(selectedProject, file, assetName, channel, subChannel)}
|
|
dropdownOptions={dropdownOptions}
|
|
onRetryAnalysis={onRetryAnalysis}
|
|
onDeleteAsset={onDeleteAsset}
|
|
/>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<CreateProjectModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onAddProject={onAddNewProject}
|
|
/>
|
|
<ProjectList
|
|
onSelectProject={onSelectProject}
|
|
projects={projects}
|
|
projectAssets={projectAssets}
|
|
onOpenModal={() => setIsModalOpen(true)}
|
|
onProjectStatusChange={onProjectStatusChange}
|
|
/>
|
|
</>
|
|
);
|
|
};
|