modcomms/frontend/App.tsx
Vadym Samoilenko 447c4b2a95 Add CSV export of campaign data for super_admin and oversight_admin
Adds a server-side CSV export covering all campaign, proof, and version
data including agent RAG statuses. The export respects the active agency
filter so oversight admins can scope the download to a single agency.

- backend: `CampaignRepository.get_export_rows()` — flat join across
  Campaign → Proof → ProofVersion with Agency and User, extracts agent
  RAG statuses from the `agent_review` JSONB column
- backend: `GET /api/export/campaigns-csv` endpoint gated to
  super_admin / oversight_admin, streams a dated CSV file
- frontend: `apiService.downloadCampaignsCsv(agencyId?)` — fetches blob
  and triggers browser download
- frontend: threads `selectedAgencyId` prop from App → Campaigns →
  CampaignList so the export uses the active filter
- frontend: Export CSV button in CampaignList header, visible only to
  super_admin / oversight_admin, with spinner while downloading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:35:24 +00:00

1027 lines
41 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 { parseUrlState, pushUrlState } from './utils/urlState';
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';
import { KnowledgeBase } from './components/KnowledgeBase';
import { UserManagement } from './components/UserManagement';
import { AgencyFilterBar } from './components/AgencyFilterBar';
import { UserProvider, useUser } from './contexts/UserContext';
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing' | 'Knowledge Base' | 'User Management';
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();
// Initialize API service with MSAL instance BEFORE rendering UserProvider
useEffect(() => {
if (msalInstance) {
apiService.setMsalInstance(msalInstance);
}
}, [msalInstance]);
// Show loading spinner during MSAL authentication interactions
if (inProgress !== InteractionStatus.None) {
return (
<div className="fixed inset-0 bg-oliver-black flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<svg className="animate-spin h-12 w-12 text-oliver-azure" 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 />;
}
return (
<UserProvider>
<AppContent msalInstance={msalInstance} />
</UserProvider>
);
};
const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => {
const isAuthenticated = true; // We're inside the authenticated boundary
const { user, isLoading: isUserLoading, canWrite, canSeeAnalytics, canSeeAuditing, canSeeKnowledgeBase, canSeeSettings, canSeeUserManagement, canEditSettings, isSuperAdmin, isOversightAdmin, isUnassigned } = useUser();
// Get initial state from URL
const initialUrlState = parseUrlState();
const [currentView, setCurrentView] = useState<View>(initialUrlState.view);
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(initialUrlState.campaignName);
const [selectedProof, setSelectedProof] = useState<any | null>(null);
const [pendingProofId, setPendingProofId] = useState<string | null>(initialUrlState.proofId);
const [error, setErrorState] = useState<string | null>(null);
const errorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setError = (msg: string | null) => {
setErrorState(msg);
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
if (msg) errorTimerRef.current = setTimeout(() => setErrorState(null), 8000);
};
const [isLoadingData, setIsLoadingData] = useState(true);
const [notification, setNotification] = useState<string | null>(null);
const notificationTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const showNotification = (message: string) => {
setNotification(message);
if (notificationTimerRef.current) clearTimeout(notificationTimerRef.current);
notificationTimerRef.current = setTimeout(() => setNotification(null), 8000);
};
// Agency filter state (session-level, for oversight_admin / super_admin)
const [selectedAgencyId, setSelectedAgencyId] = useState<string | null>(null);
const showAgencyFilter = isSuperAdmin || isOversightAdmin;
// 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 (re-fetch when agency filter changes)
useEffect(() => {
const loadCampaigns = async () => {
if (!isAuthenticated || isUserLoading) return;
setIsLoadingData(true);
try {
const response = await apiService.getCampaigns(selectedAgencyId || undefined);
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, isUserLoading, selectedAgencyId]);
// 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 (re-fetch when agency filter changes)
useEffect(() => {
const loadAuditItems = async () => {
if (!isAuthenticated || isUserLoading) return;
try {
const agencyFilter = selectedAgencyId || undefined;
const [flagged, resolved, errors] = await Promise.all([
apiService.getFlaggedItems(agencyFilter),
apiService.getResolvedItems(agencyFilter),
apiService.getErrorItems(agencyFilter),
]);
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, isUserLoading, selectedAgencyId]);
// Sync state changes to URL
useEffect(() => {
pushUrlState({
view: currentView,
campaignName: selectedCampaign,
proofId: selectedProof?._id || null,
});
}, [currentView, selectedCampaign, selectedProof]);
// Handle browser back/forward
useEffect(() => {
const handlePopState = () => {
const state = parseUrlState();
setCurrentView(state.view);
setSelectedCampaign(state.campaignName);
if (!state.proofId) setSelectedProof(null);
setPendingProofId(state.proofId);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Load proofs for campaign selected from URL (deep linking support)
useEffect(() => {
const loadProofsForUrlCampaign = async () => {
if (!selectedCampaign || !isAuthenticated || campaigns.length === 0) return;
// Skip if proofs already loaded for this campaign
if (campaignProofs[selectedCampaign]) return;
const campaign = campaigns.find(c => c.name === selectedCampaign);
if (!campaign?._id) return;
try {
const proofs = await apiService.getProofs(campaign._id);
setCampaignProofs(prev => ({
...prev,
[selectedCampaign]: proofs.map(p => apiService.convertProofToFrontend(p))
}));
} catch (error) {
console.error('Failed to load proofs for URL campaign:', selectedCampaign, error);
}
};
loadProofsForUrlCampaign();
}, [selectedCampaign, isAuthenticated, campaigns, campaignProofs]);
// Restore proof selection from URL after proofs are loaded
useEffect(() => {
if (pendingProofId && selectedCampaign && campaignProofs[selectedCampaign]) {
const proof = campaignProofs[selectedCampaign].find(p => p._id === pendingProofId);
if (proof) {
setSelectedProof(proof);
}
setPendingProofId(null);
}
}, [pendingProofId, selectedCampaign, campaignProofs]);
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,
brand: campaign.brandGuidelines,
}, showNotification);
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 analyse proof. Please try again.");
setCampaignProofs(prevProofs => ({
...prevProofs,
[campaignName]: prevProofs[campaignName].map(proof =>
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
)
}));
}
};
const handleRetryAnalysis = async (campaignName: string, proofId: string) => {
const matchProof = (proof: any) => proof.tempId === proofId || proof._id === proofId;
const proofToRetry = campaignProofs[campaignName]?.find(matchProof);
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 =>
matchProof(proof) ? { ...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 (matchProof(proof) && 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,
brand: campaign.brandGuidelines,
}, showNotification);
// 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);
setCampaignProofs(prevProofs => ({
...prevProofs,
[campaignName]: prevProofs[campaignName].filter(p => !matchProof(p))
}));
}
// 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 =>
matchProof(proof) ? { ...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.');
}
};
const handleDeleteCampaign = async (campaignName: string) => {
const campaign = campaigns.find(c => c.name === campaignName);
if (!campaign?._id) {
setError('Campaign not found.');
return;
}
try {
await apiService.deleteCampaign(campaign._id);
// Update local state after successful deletion
setCampaigns(prev => prev.filter(c => c.name !== campaignName));
setCampaignProofs(prev => {
const newProofs = { ...prev };
delete newProofs[campaignName];
return newProofs;
});
} catch (error) {
console.error('Error deleting campaign:', error);
setError('Failed to delete campaign. Please try again.');
}
};
// --- 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 = async (item: { campaignName: string; proofName: string; version: number }) => {
// Use cached proofs or fetch from API (e.g. Oversight Admin going straight to Auditing)
let proofs = campaignProofs[item.campaignName];
if (!proofs) {
const campaign = campaigns.find(c => c.name === item.campaignName);
if (!campaign?._id) {
alert(`Campaign "${item.campaignName}" not found. It may have been deleted.`);
return;
}
try {
const rawProofs = await apiService.getProofs(campaign._id);
proofs = rawProofs.map(p => apiService.convertProofToFrontend(p));
setCampaignProofs(prev => ({ ...prev, [item.campaignName]: proofs! }));
} catch (error) {
console.error('Failed to load proofs for campaign:', item.campaignName, error);
alert('Failed to load proofs. Please try again.');
return;
}
}
const proofToSelect = proofs.find(a => a.proofName === item.proofName);
if (proofToSelect) {
const versionExists = proofToSelect.versions.some((v: any) => v.version === item.version);
if (versionExists) {
setSelectedCampaign(item.campaignName);
setSelectedProof({ ...proofToSelect, initialVersion: item.version });
setCurrentView('Campaigns');
} else {
alert(`Version ${item.version} not found for proof "${item.proofName}". It may have been deleted.`);
}
} else {
alert(`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 + import.meta.env.BASE_URL,
});
} catch (error) {
console.error('Logout failed:', error);
}
};
const readOnly = !canWrite || (isOversightAdmin && !user?.agencyId);
const renderContent = () => {
switch (currentView) {
case 'Analytics':
return <Analytics agencyId={selectedAgencyId || undefined} isAdmin={isSuperAdmin || isOversightAdmin} />;
case 'Profile':
return <Profile onLogout={handleLogout} msalInstance={msalInstance} />;
case 'CopyGenAI':
return <CopyGenAI />;
case 'Campaigns':
if (isUnassigned) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="text-5xl mb-4">🏢</div>
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Agency Assigned</h2>
<p className="text-gray-500">
You are not currently assigned to an agency. Please contact your administrator to be assigned before you can view or create campaigns.
</p>
</div>
</div>
);
}
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}
onDeleteCampaign={handleDeleteCampaign}
onFlagSubmit={handleFlagSubmit}
onResolveSubmit={handleResolveSubmit}
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
readOnly={readOnly || isUnassigned}
selectedAgencyId={selectedAgencyId}
/>;
case 'WIP Reviewer':
return <WIPReviewer dropdownOptions={dropdownOptions} msalInstance={msalInstance} />;
case 'Auditing':
return <Auditing
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
errorItems={errorItems}
onNavigate={handleNavigateToAuditedItem}
/>;
case 'Knowledge Base':
return <KnowledgeBase />;
case 'User Management':
return <UserManagement />;
case 'Settings':
return <Settings
options={dropdownOptions}
onAddCampaign={handleAddCampaignOption}
onRemoveCampaign={handleRemoveCampaignOption}
onAddChannel={handleAddChannel}
onRemoveChannel={handleRemoveChannel}
onAddSubChannel={handleAddSubChannel}
onRemoveSubChannel={handleRemoveSubChannel}
onAddProofType={handleAddProofType}
onRemoveProofType={handleRemoveProofType}
readOnly={!canEditSettings}
/>;
case 'Home':
default:
return (
<>
<Hero onGetStarted={() => handleNavigate('Campaigns')} />
{/* Hidden per Oliver design — client may want it back */}
{/* <ChecksOverview /> */}
</>
);
}
};
// Show loading spinner while user profile is loading
if (isUserLoading) {
return (
<div className="fixed inset-0 bg-oliver-black flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<svg className="animate-spin h-12 w-12 text-oliver-azure" 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">Loading user profile...</p>
</div>
</div>
);
}
// Determine background color based on view to avoid grey bar on Home view
const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-oliver-grey';
// 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 min-w-[900px]`}>
<Sidebar
activeItem={currentView}
onNavigate={(view) => handleNavigate(view as View)}
userName={user?.name || userInfo?.name}
userEmail={user?.email || userInfo?.email}
userRole={user?.role || 'basic_user'}
/>
<div className="flex-1 flex flex-col overflow-y-auto">
{showAgencyFilter && (
<AgencyFilterBar
selectedAgencyId={selectedAgencyId}
onAgencyChange={setSelectedAgencyId}
/>
)}
<main className="flex-1 flex flex-col min-h-full">
{renderContent()}
</main>
</div>
{/* Error toast */}
{error && (
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 bg-error text-white text-sm px-5 py-3.5 rounded-xl shadow-2xl max-w-md w-full mx-4 animate-fade-in">
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<p className="flex-1 leading-snug">{error}</p>
<button onClick={() => setErrorState(null)} className="text-white/70 hover:text-white shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Model fallback notification toast */}
{notification && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 bg-oliver-orange text-oliver-black text-sm px-5 py-3.5 rounded-xl shadow-2xl max-w-md w-full mx-4 animate-fade-in">
<svg className="w-5 h-5 text-oliver-black shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<div className="flex-1">
<p className="font-semibold text-oliver-black mb-0.5">AI Model Notice</p>
<p className="text-oliver-black leading-snug">{notification}</p>
</div>
<button onClick={() => setNotification(null)} className="text-oliver-black/60 hover:text-oliver-black shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
);
};
export default App;