- Add GET /files/{storage_key:path} endpoint to serve stored files
- Add getFile() method to apiService to fetch files from backend
- Update convertProofToFrontend() to preserve fileStorageKey
- Update handleRetryAnalysis() to fetch file from backend when not in memory
- Update handleDownload() to download original file instead of thumbnail
After page refresh, the retry button now fetches the original file from
backend storage using the fileStorageKey, allowing failed proofs to be
reprocessed. The Download Asset button also now downloads the original
uploaded file rather than the preview thumbnail.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
808 lines
31 KiB
TypeScript
Executable file
808 lines
31 KiB
TypeScript
Executable file
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
|
|
import { InteractionStatus } from '@azure/msal-browser';
|
|
import { Hero } from './components/Hero';
|
|
import { analyzeProof } from './services/geminiService';
|
|
import { getUserInfo } from './services/authService';
|
|
import apiService from './services/apiService';
|
|
import type { AgentReview, AgentName, FlaggedItem, ResolvedItem, ErrorItem } from './types';
|
|
import { AGENT_NAMES } from './constants';
|
|
import { Sidebar } from './components/Sidebar';
|
|
import { ChecksOverview } from './components/ChecksOverview';
|
|
import { Analytics } from './components/Analytics';
|
|
import { Profile } from './components/Profile';
|
|
import { CopyGenAI } from './components/CopyGenAI';
|
|
import { Settings } from './components/Settings';
|
|
import { Campaigns } from './components/Campaigns';
|
|
import { Auditing } from './components/Auditing';
|
|
import { Login } from './components/Login';
|
|
import { WIPReviewer } from './components/WIPReviewer';
|
|
|
|
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing';
|
|
|
|
export interface DropdownOptions {
|
|
campaigns: string[];
|
|
// Hierarchy: Channel -> SubChannel -> ProofType[]
|
|
channels: Record<string, Record<string, string[]>>;
|
|
// Brand guidelines for campaign creation
|
|
brandGuidelines: string[];
|
|
}
|
|
|
|
const App: React.FC = () => {
|
|
// MSAL authentication state
|
|
const isAuthenticated = useIsAuthenticated();
|
|
const { instance: msalInstance, inProgress } = useMsal();
|
|
|
|
const [currentView, setCurrentView] = useState<View>('Home');
|
|
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
|
|
const [selectedProof, setSelectedProof] = useState<any | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
|
|
|
// Initialize API service with MSAL instance for authenticated requests
|
|
useEffect(() => {
|
|
if (msalInstance) {
|
|
apiService.setMsalInstance(msalInstance);
|
|
}
|
|
}, [msalInstance]);
|
|
|
|
// Dropdown options now loaded from API
|
|
const [dropdownOptions, setDropdownOptions] = useState<DropdownOptions>({
|
|
campaigns: [],
|
|
channels: {},
|
|
brandGuidelines: []
|
|
});
|
|
|
|
// Load dropdown options from API when authenticated
|
|
useEffect(() => {
|
|
const loadDropdownOptions = async () => {
|
|
if (!isAuthenticated) return;
|
|
|
|
try {
|
|
const options = await apiService.getDropdownOptions();
|
|
console.log('[DEBUG App.tsx] Loaded dropdown options from API');
|
|
console.log('[DEBUG App.tsx] options.channels:', JSON.stringify(options.channels, null, 2));
|
|
console.log('[DEBUG App.tsx] Social.Meta proof types:', options.channels?.Social?.Meta);
|
|
setDropdownOptions({
|
|
campaigns: options.campaigns || [],
|
|
channels: options.channels || {},
|
|
brandGuidelines: options.brand_guidelines || []
|
|
});
|
|
} catch (error) {
|
|
console.error('[DEBUG App.tsx] Failed to load dropdown options:', error);
|
|
// Fall back to default options if API fails
|
|
setDropdownOptions({
|
|
campaigns: [],
|
|
channels: {
|
|
"Social": { "Meta": ["In-feed 1x1", "In-feed 4x5"] },
|
|
"Display": { "Banner": ["300x600", "300x250"] }
|
|
},
|
|
brandGuidelines: ["Barclays", "Barclaycard"]
|
|
});
|
|
}
|
|
};
|
|
|
|
loadDropdownOptions();
|
|
}, [isAuthenticated]);
|
|
|
|
|
|
// Campaigns and proofs now loaded from API instead of localStorage
|
|
const [campaigns, setCampaigns] = useState<any[]>([]);
|
|
const [campaignProofs, setCampaignProofs] = useState<Record<string, any[]>>({});
|
|
|
|
// Load campaigns from API when authenticated
|
|
useEffect(() => {
|
|
const loadCampaigns = async () => {
|
|
if (!isAuthenticated) return;
|
|
|
|
setIsLoadingData(true);
|
|
try {
|
|
const response = await apiService.getCampaigns();
|
|
setCampaigns(response.map(c => apiService.convertCampaignToFrontend(c)));
|
|
} catch (error) {
|
|
console.error('Failed to load campaigns:', error);
|
|
setError('Failed to load campaigns. Please try again.');
|
|
} finally {
|
|
setIsLoadingData(false);
|
|
}
|
|
};
|
|
|
|
loadCampaigns();
|
|
}, [isAuthenticated]);
|
|
|
|
// Audit items now loaded from API instead of localStorage
|
|
const [flaggedItems, setFlaggedItems] = useState<FlaggedItem[]>([]);
|
|
const [resolvedItems, setResolvedItems] = useState<ResolvedItem[]>([]);
|
|
const [errorItems, setErrorItems] = useState<ErrorItem[]>([]);
|
|
|
|
// Load audit items from API when authenticated
|
|
useEffect(() => {
|
|
const loadAuditItems = async () => {
|
|
if (!isAuthenticated) return;
|
|
|
|
try {
|
|
const [flagged, resolved, errors] = await Promise.all([
|
|
apiService.getFlaggedItems(),
|
|
apiService.getResolvedItems(),
|
|
apiService.getErrorItems(),
|
|
]);
|
|
setFlaggedItems(flagged.map(i => apiService.convertFlaggedItemToFrontend(i)));
|
|
setResolvedItems(resolved.map(i => apiService.convertResolvedItemToFrontend(i)));
|
|
setErrorItems(errors.map(i => apiService.convertErrorItemToFrontend(i)));
|
|
} catch (error) {
|
|
console.error('Failed to load audit items:', error);
|
|
}
|
|
};
|
|
|
|
loadAuditItems();
|
|
}, [isAuthenticated]);
|
|
|
|
useEffect(() => {
|
|
// Keep selectedProof in sync with the master list in campaignProofs.
|
|
// This ensures that when a new version is added, the detail view refreshes.
|
|
if (selectedCampaign && selectedProof && campaignProofs[selectedCampaign]) {
|
|
const currentCampaignProofs = campaignProofs[selectedCampaign];
|
|
const freshProof = currentCampaignProofs.find(a => !a.tempId && a.proofName === selectedProof.proofName);
|
|
|
|
// A simple stringify check to prevent re-renders if the object is the same.
|
|
if (freshProof && JSON.stringify(freshProof) !== JSON.stringify(selectedProof)) {
|
|
setSelectedProof(freshProof);
|
|
}
|
|
}
|
|
}, [campaignProofs, selectedCampaign, selectedProof]);
|
|
|
|
const handleAddNewCampaign = async (campaignData: { name: string; workfrontId: string; clientLead: string; agencyLead: string; brandGuidelines: string; }) => {
|
|
try {
|
|
const response = await apiService.createCampaign({
|
|
name: campaignData.name,
|
|
workfront_id: campaignData.workfrontId,
|
|
client_lead: campaignData.clientLead,
|
|
agency_lead: campaignData.agencyLead,
|
|
brand_guidelines: campaignData.brandGuidelines,
|
|
});
|
|
|
|
const newCampaign = apiService.convertCampaignToFrontend(response);
|
|
setCampaigns(prev => [...prev, newCampaign]);
|
|
setCampaignProofs(prev => ({ ...prev, [newCampaign.name]: [] }));
|
|
} catch (error) {
|
|
console.error('Error creating campaign:', error);
|
|
setError('Failed to create campaign. Please try again.');
|
|
}
|
|
};
|
|
|
|
const fileToDataUrl = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const handleProofUploadForCampaign = async (
|
|
campaignName: string,
|
|
file: File,
|
|
proofName: string,
|
|
channel: string,
|
|
subChannel: string,
|
|
proofType?: string
|
|
) => {
|
|
setError(null);
|
|
|
|
// Find the campaign to get its ID for API persistence
|
|
const campaign = campaigns.find(c => c.name === campaignName);
|
|
if (!campaign?._id) {
|
|
setError('Campaign not found. Please refresh and try again.');
|
|
return;
|
|
}
|
|
|
|
const tempId = `temp_${Date.now()}`;
|
|
const newProofPlaceholder = {
|
|
tempId,
|
|
proofName,
|
|
channel,
|
|
subChannel,
|
|
proofType,
|
|
status: 'analyzing',
|
|
analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 },
|
|
file,
|
|
versions: [],
|
|
};
|
|
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: [newProofPlaceholder, ...(prevProofs[campaignName] || [])]
|
|
}));
|
|
|
|
const handleAgentUpdate = (agentName: AgentName | 'Summary') => {
|
|
setCampaignProofs(prevProofs => {
|
|
const currentProofs = prevProofs[campaignName] || [];
|
|
const updatedProofs = currentProofs.map(proof => {
|
|
if (proof.tempId === tempId && proof.status === 'analyzing') {
|
|
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
|
|
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
|
|
}
|
|
return proof;
|
|
});
|
|
return { ...prevProofs, [campaignName]: updatedProofs };
|
|
});
|
|
};
|
|
|
|
try {
|
|
// Pass campaign context to persist proof in database
|
|
const result = await analyzeProof(file, handleAgentUpdate, msalInstance, {
|
|
campaignId: campaign._id,
|
|
proofName,
|
|
channel,
|
|
subChannel,
|
|
proofType,
|
|
});
|
|
|
|
const feedback = result.review;
|
|
|
|
// Refresh proofs from API to get the persisted data
|
|
// This ensures we have the correct IDs and data from the database
|
|
try {
|
|
const proofs = await apiService.getProofs(campaign._id);
|
|
setCampaignProofs(prev => ({
|
|
...prev,
|
|
[campaignName]: proofs.map(p => apiService.convertProofToFrontend(p))
|
|
}));
|
|
} catch (refreshError) {
|
|
console.error('Failed to refresh proofs after analysis:', refreshError);
|
|
// Fallback: remove the temp placeholder since analysis succeeded
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].filter(p => p.tempId !== tempId)
|
|
}));
|
|
}
|
|
|
|
// Refresh campaigns list to get updated proof count
|
|
try {
|
|
const campaignsResponse = await apiService.getCampaigns();
|
|
setCampaigns(campaignsResponse.map(c => apiService.convertCampaignToFrontend(c)));
|
|
} catch (refreshError) {
|
|
console.error('Failed to refresh campaigns after analysis:', refreshError);
|
|
}
|
|
|
|
// If analysis resulted in error, log it (error items are now tracked on backend)
|
|
if (feedback.overallStatus === 'Analysis Error') {
|
|
console.warn('Proof analysis resulted in error status:', feedback.leadAgentSummary);
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("Failed to upload and analyze proof:", err);
|
|
setError("Failed to upload and analyze proof. Please try again.");
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].map(proof =>
|
|
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
|
|
)
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleRetryAnalysis = async (campaignName: string, tempId: string) => {
|
|
const proofToRetry = campaignProofs[campaignName]?.find(proof => proof.tempId === tempId);
|
|
|
|
if (!proofToRetry) {
|
|
console.error("Proof to retry not found");
|
|
return;
|
|
}
|
|
|
|
// Find the campaign to get its ID for API persistence
|
|
const campaign = campaigns.find(c => c.name === campaignName);
|
|
if (!campaign?._id) {
|
|
setError('Campaign not found. Please refresh and try again.');
|
|
return;
|
|
}
|
|
|
|
let file = proofToRetry.file;
|
|
|
|
// Fetch from backend if not in memory
|
|
if (!file) {
|
|
const storageKey = proofToRetry.fileStorageKey || proofToRetry.versions?.[0]?.fileStorageKey;
|
|
if (!storageKey) {
|
|
setError('Cannot retry: original file is not available.');
|
|
return;
|
|
}
|
|
try {
|
|
file = await apiService.getFile(storageKey);
|
|
} catch (err) {
|
|
console.error("Failed to fetch file for retry:", err);
|
|
setError('Failed to retrieve original file for retry.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { proofName, channel, subChannel, proofType } = proofToRetry;
|
|
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].map(proof =>
|
|
proof.tempId === tempId ? { ...proof, status: 'analyzing', analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 } } : proof
|
|
)
|
|
}));
|
|
|
|
const handleAgentUpdateForRetry = (agentName: AgentName | 'Summary') => {
|
|
setCampaignProofs(prevProofs => {
|
|
const currentProofs = prevProofs[campaignName] || [];
|
|
const updatedProofs = currentProofs.map(proof => {
|
|
if (proof.tempId === tempId && proof.status === 'analyzing') {
|
|
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
|
|
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
|
|
}
|
|
return proof;
|
|
});
|
|
return { ...prevProofs, [campaignName]: updatedProofs };
|
|
});
|
|
};
|
|
|
|
try {
|
|
// Pass campaign context to persist proof in database
|
|
const result = await analyzeProof(file, handleAgentUpdateForRetry, msalInstance, {
|
|
campaignId: campaign._id,
|
|
proofName,
|
|
channel,
|
|
subChannel,
|
|
proofType,
|
|
});
|
|
|
|
// Refresh proofs from API to get the persisted data
|
|
try {
|
|
const proofs = await apiService.getProofs(campaign._id);
|
|
setCampaignProofs(prev => ({
|
|
...prev,
|
|
[campaignName]: proofs.map(p => apiService.convertProofToFrontend(p))
|
|
}));
|
|
} catch (refreshError) {
|
|
console.error('Failed to refresh proofs after retry:', refreshError);
|
|
// Remove the temp placeholder since analysis succeeded
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].filter(p => p.tempId !== tempId)
|
|
}));
|
|
}
|
|
|
|
// Refresh campaigns list to get updated proof count
|
|
try {
|
|
const campaignsResponse = await apiService.getCampaigns();
|
|
setCampaigns(campaignsResponse.map(c => apiService.convertCampaignToFrontend(c)));
|
|
} catch (refreshError) {
|
|
console.error('Failed to refresh campaigns after retry:', refreshError);
|
|
}
|
|
|
|
if (result.review.overallStatus === 'Analysis Error') {
|
|
console.warn('Retry analysis resulted in error status:', result.review.leadAgentSummary);
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("Failed to retry proof analysis:", err);
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].map(proof =>
|
|
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
|
|
)
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleCampaignStatusChange = async (campaignName: string, newStatus: 'In Progress' | 'Completed') => {
|
|
const campaign = campaigns.find(c => c.name === campaignName);
|
|
if (!campaign?._id) {
|
|
setError('Campaign not found.');
|
|
return;
|
|
}
|
|
|
|
const previousStatus = campaign.status;
|
|
const previousLastModified = campaign.lastModified;
|
|
|
|
// Optimistically update local state immediately
|
|
setCampaigns(prev => prev.map(p =>
|
|
p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString() } : p
|
|
));
|
|
|
|
try {
|
|
await apiService.updateCampaign(campaign._id, { status: newStatus });
|
|
} catch (error) {
|
|
console.error('Error updating campaign status:', error);
|
|
setError('Failed to update campaign status.');
|
|
// Rollback on error
|
|
setCampaigns(prev => prev.map(p =>
|
|
p.name === campaignName ? { ...p, status: previousStatus, lastModified: previousLastModified } : p
|
|
));
|
|
}
|
|
};
|
|
|
|
const handleDeleteProof = async (campaignName: string, proofName: string) => {
|
|
// Find the proof to get its ID
|
|
const proofToDelete = campaignProofs[campaignName]?.find(p => p.proofName === proofName);
|
|
if (!proofToDelete?._id) {
|
|
// If no _id, it might be a temp proof that hasn't been saved to DB yet
|
|
// Just remove it from local state
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].filter(p => p.proofName !== proofName)
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiService.deleteProof(proofToDelete._id);
|
|
|
|
// Update local state after successful deletion
|
|
setCampaignProofs(prevProofs => ({
|
|
...prevProofs,
|
|
[campaignName]: prevProofs[campaignName].filter(p => p.proofName !== proofName)
|
|
}));
|
|
|
|
// Refresh campaigns to get updated proof count
|
|
const campaignsResponse = await apiService.getCampaigns();
|
|
setCampaigns(campaignsResponse.map(c => apiService.convertCampaignToFrontend(c)));
|
|
} catch (error) {
|
|
console.error('Error deleting proof:', error);
|
|
setError('Failed to delete proof.');
|
|
}
|
|
};
|
|
|
|
// --- SETTINGS HANDLERS (NOW USE API) ---
|
|
|
|
// Helper to refresh dropdown options from API
|
|
const refreshDropdownOptions = async () => {
|
|
try {
|
|
const options = await apiService.getDropdownOptions();
|
|
setDropdownOptions({
|
|
campaigns: options.campaigns || [],
|
|
channels: options.channels || {},
|
|
brandGuidelines: options.brand_guidelines || []
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to refresh dropdown options:', error);
|
|
}
|
|
};
|
|
|
|
// Campaign options are not stored in dropdown_options table - keeping local for now
|
|
const handleAddCampaignOption = (value: string) => {
|
|
setDropdownOptions(prev => {
|
|
if (!prev.campaigns.includes(value)) {
|
|
return { ...prev, campaigns: [...prev.campaigns, value].sort() };
|
|
}
|
|
return prev;
|
|
});
|
|
};
|
|
|
|
const handleRemoveCampaignOption = (value: string) => {
|
|
setDropdownOptions(prev => ({ ...prev, campaigns: prev.campaigns.filter(c => c !== value) }));
|
|
};
|
|
|
|
const handleAddChannel = async (channel: string) => {
|
|
try {
|
|
await apiService.addChannel(channel);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => ({
|
|
...prev,
|
|
channels: { ...prev.channels, [channel]: {} }
|
|
}));
|
|
} catch (error) {
|
|
console.error('Error adding channel:', error);
|
|
setError('Failed to add channel.');
|
|
}
|
|
};
|
|
|
|
const handleRemoveChannel = async (channel: string) => {
|
|
try {
|
|
await apiService.deleteChannel(channel);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => {
|
|
const newChannels = { ...prev.channels };
|
|
delete newChannels[channel];
|
|
return { ...prev, channels: newChannels };
|
|
});
|
|
} catch (error) {
|
|
console.error('Error removing channel:', error);
|
|
setError('Failed to remove channel.');
|
|
}
|
|
};
|
|
|
|
const handleAddSubChannel = async (channel: string, subChannel: string) => {
|
|
try {
|
|
await apiService.addSubChannel(channel, subChannel);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => ({
|
|
...prev,
|
|
channels: {
|
|
...prev.channels,
|
|
[channel]: { ...prev.channels[channel], [subChannel]: [] }
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
console.error('Error adding sub-channel:', error);
|
|
setError('Failed to add sub-channel.');
|
|
}
|
|
};
|
|
|
|
const handleRemoveSubChannel = async (channel: string, subChannel: string) => {
|
|
try {
|
|
await apiService.deleteSubChannel(channel, subChannel);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => {
|
|
const channelData = { ...prev.channels[channel] };
|
|
delete channelData[subChannel];
|
|
return { ...prev, channels: { ...prev.channels, [channel]: channelData } };
|
|
});
|
|
} catch (error) {
|
|
console.error('Error removing sub-channel:', error);
|
|
setError('Failed to remove sub-channel.');
|
|
}
|
|
};
|
|
|
|
const handleAddProofType = async (channel: string, subChannel: string, proofType: string) => {
|
|
try {
|
|
await apiService.addProofType(channel, subChannel, proofType);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => {
|
|
const channelData = prev.channels[channel] || {};
|
|
const currentTypes = channelData[subChannel] || [];
|
|
return {
|
|
...prev,
|
|
channels: {
|
|
...prev.channels,
|
|
[channel]: {
|
|
...channelData,
|
|
[subChannel]: [...currentTypes, proofType].sort()
|
|
}
|
|
}
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Error adding proof type:', error);
|
|
setError('Failed to add proof type.');
|
|
}
|
|
};
|
|
|
|
const handleRemoveProofType = async (channel: string, subChannel: string, proofType: string) => {
|
|
try {
|
|
await apiService.deleteProofType(channel, subChannel, proofType);
|
|
// Optimistically update local state
|
|
setDropdownOptions(prev => {
|
|
const channelData = prev.channels[channel];
|
|
if (!channelData) return prev;
|
|
const currentTypes = channelData[subChannel] || [];
|
|
return {
|
|
...prev,
|
|
channels: {
|
|
...prev.channels,
|
|
[channel]: {
|
|
...channelData,
|
|
[subChannel]: currentTypes.filter(t => t !== proofType)
|
|
}
|
|
}
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Error removing proof type:', error);
|
|
setError('Failed to remove proof type.');
|
|
}
|
|
};
|
|
|
|
|
|
const handleNavigate = (view: View) => {
|
|
setCurrentView(view);
|
|
setSelectedCampaign(null); // Reset campaign on any main navigation
|
|
setSelectedProof(null);
|
|
};
|
|
|
|
const handleSelectCampaign = async (campaignName: string) => {
|
|
setSelectedCampaign(campaignName);
|
|
|
|
// Find the campaign to get its ID for API calls
|
|
const campaign = campaigns.find(c => c.name === campaignName);
|
|
if (!campaign?._id) {
|
|
console.error('Campaign not found or missing ID:', campaignName);
|
|
return;
|
|
}
|
|
|
|
// Load proofs from API if not already cached
|
|
if (!campaignProofs[campaignName]) {
|
|
try {
|
|
const proofs = await apiService.getProofs(campaign._id);
|
|
setCampaignProofs(prev => ({
|
|
...prev,
|
|
[campaignName]: proofs.map(p => apiService.convertProofToFrontend(p))
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to load proofs for campaign:', campaignName, error);
|
|
setError('Failed to load proofs. Please try again.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSelectProof = (proof: any) => {
|
|
setSelectedProof(proof);
|
|
};
|
|
|
|
const handleBackToCampaignsList = () => {
|
|
setSelectedCampaign(null);
|
|
setSelectedProof(null);
|
|
};
|
|
|
|
const handleBackToCampaignDetails = () => {
|
|
setSelectedProof(null);
|
|
};
|
|
|
|
const handleFlagSubmit = async (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
|
|
// Find the proof to get its ID
|
|
const proof = campaignProofs[flagData.campaignName]?.find(p => p.proofName === flagData.proofName);
|
|
if (!proof?._id) {
|
|
setError('Proof not found. Unable to submit flag.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiService.flagProofVersion(
|
|
proof._id,
|
|
flagData.version,
|
|
{ agent_flagged: flagData.agentFlagged, comments: flagData.comments }
|
|
);
|
|
const newFlag = apiService.convertFlaggedItemToFrontend(response);
|
|
setFlaggedItems(prev => [newFlag, ...prev]);
|
|
} catch (error) {
|
|
console.error('Error flagging proof version:', error);
|
|
setError('Failed to submit flag. Please try again.');
|
|
}
|
|
};
|
|
|
|
const handleResolveSubmit = async (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
|
|
// Find the proof to get its ID
|
|
const proof = campaignProofs[resolveData.campaignName]?.find(p => p.proofName === resolveData.proofName);
|
|
if (!proof?._id) {
|
|
setError('Proof not found. Unable to submit resolution.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiService.resolveProofVersion(
|
|
proof._id,
|
|
resolveData.version,
|
|
{ agent: resolveData.agent, issue: resolveData.issue, resolution: resolveData.resolution }
|
|
);
|
|
const newResolution = apiService.convertResolvedItemToFrontend(response);
|
|
setResolvedItems(prev => [newResolution, ...prev]);
|
|
} catch (error) {
|
|
console.error('Error resolving proof version:', error);
|
|
setError('Failed to submit resolution. Please try again.');
|
|
}
|
|
};
|
|
|
|
|
|
const handleNavigateToAuditedItem = (item: { campaignName: string; proofName: string; version: number }) => {
|
|
const proofToSelect = campaignProofs[item.campaignName]?.find(a => a.proofName === item.proofName);
|
|
if (proofToSelect) {
|
|
const versionExists = proofToSelect.versions.some((v: any) => v.version === item.version);
|
|
if (versionExists) {
|
|
setSelectedCampaign(item.campaignName);
|
|
// Add a temporary property to the proof object to indicate which version to show.
|
|
setSelectedProof({ ...proofToSelect, initialVersion: item.version });
|
|
setCurrentView('Campaigns');
|
|
} else {
|
|
setError(`Version ${item.version} not found for proof ${item.proofName}. It may have been deleted.`);
|
|
}
|
|
} else {
|
|
setError(`Proof ${item.proofName} not found in campaign ${item.campaignName}. It may have been deleted.`);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await msalInstance.logoutPopup({
|
|
postLogoutRedirectUri: window.location.origin,
|
|
});
|
|
} catch (error) {
|
|
console.error('Logout failed:', error);
|
|
}
|
|
};
|
|
|
|
const renderContent = () => {
|
|
switch (currentView) {
|
|
case 'Analytics':
|
|
return <Analytics />;
|
|
case 'Profile':
|
|
return <Profile onLogout={handleLogout} msalInstance={msalInstance} />;
|
|
case 'CopyGenAI':
|
|
return <CopyGenAI />;
|
|
case 'Campaigns':
|
|
return <Campaigns
|
|
selectedCampaign={selectedCampaign}
|
|
selectedProof={selectedProof}
|
|
onSelectCampaign={handleSelectCampaign}
|
|
onSelectProof={handleSelectProof}
|
|
onBackToCampaignsList={handleBackToCampaignsList}
|
|
onBackToCampaignDetails={handleBackToCampaignDetails}
|
|
campaigns={campaigns}
|
|
campaignProofs={campaignProofs}
|
|
onAddNewCampaign={handleAddNewCampaign}
|
|
onProofUpload={handleProofUploadForCampaign}
|
|
dropdownOptions={dropdownOptions}
|
|
onRetryAnalysis={handleRetryAnalysis}
|
|
onCampaignStatusChange={handleCampaignStatusChange}
|
|
onDeleteProof={handleDeleteProof}
|
|
onFlagSubmit={handleFlagSubmit}
|
|
onResolveSubmit={handleResolveSubmit}
|
|
/>;
|
|
case 'WIP Reviewer':
|
|
return <WIPReviewer dropdownOptions={dropdownOptions} msalInstance={msalInstance} />;
|
|
case 'Auditing':
|
|
return <Auditing
|
|
flaggedItems={flaggedItems}
|
|
resolvedItems={resolvedItems}
|
|
errorItems={errorItems}
|
|
onNavigate={handleNavigateToAuditedItem}
|
|
/>;
|
|
case 'Settings':
|
|
return <Settings
|
|
options={dropdownOptions}
|
|
onAddCampaign={handleAddCampaignOption}
|
|
onRemoveCampaign={handleRemoveCampaignOption}
|
|
onAddChannel={handleAddChannel}
|
|
onRemoveChannel={handleRemoveChannel}
|
|
onAddSubChannel={handleAddSubChannel}
|
|
onRemoveSubChannel={handleRemoveSubChannel}
|
|
onAddProofType={handleAddProofType}
|
|
onRemoveProofType={handleRemoveProofType}
|
|
onNavigate={handleNavigate}
|
|
/>;
|
|
case 'Home':
|
|
default:
|
|
return (
|
|
<>
|
|
<Hero onGetStarted={() => handleNavigate('Campaigns')} />
|
|
<ChecksOverview />
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
// Show loading spinner during MSAL authentication interactions
|
|
if (inProgress !== InteractionStatus.None) {
|
|
return (
|
|
<div className="fixed inset-0 bg-[#0f172a] flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<svg className="animate-spin h-12 w-12 text-brand-accent" 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>
|
|
<p className="text-white/80 text-sm">Authenticating...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return <Login />;
|
|
}
|
|
|
|
// Determine background color based on view to avoid grey bar on Home view
|
|
const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-brand-gray';
|
|
|
|
// Get user info from MSAL for sidebar display
|
|
const userInfo = getUserInfo(msalInstance);
|
|
|
|
return (
|
|
<div className={`flex h-screen ${mainBgColor} font-sans text-gray-800 overflow-hidden`}>
|
|
<Sidebar
|
|
activeItem={currentView}
|
|
onNavigate={(view) => handleNavigate(view as View)}
|
|
userName={userInfo?.name}
|
|
userEmail={userInfo?.email}
|
|
/>
|
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
<main className="flex-1 flex flex-col min-h-full">
|
|
{renderContent()}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|