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>
1027 lines
41 KiB
TypeScript
Executable file
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;
|