From c07c66a583b5fdd8acf78eeac498a3f699aea839 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 18 Dec 2025 13:50:37 -0600 Subject: [PATCH] Connect frontend to PostgreSQL database via API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all localStorage-based state management with API calls - Load campaigns, proofs, and audit items from database - Persist proof analysis results to database via WebSocket - Add dropdown options CRUD API endpoints (channels, sub-channels, proof types) - Create DropdownRepository for managing dropdown options - Update Analytics component to fetch data from API - Remove demo data and localStorage persistence code Frontend changes: - App.tsx: Initialize apiService with MSAL, use API for all CRUD operations - apiService.ts: Add dropdown options API methods - Analytics.tsx: Fetch stats from /api/analytics Backend changes: - New dropdown_repository.py for dropdown CRUD - routes.py: Add 7 dropdown endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/api/routes.py | 104 +++ backend/app/repositories/__init__.py | 2 + .../app/repositories/dropdown_repository.py | 202 +++++ frontend/App.tsx | 856 ++++++++---------- frontend/components/Analytics.tsx | 40 +- frontend/services/apiService.ts | 46 + 6 files changed, 778 insertions(+), 472 deletions(-) create mode 100644 backend/app/repositories/dropdown_repository.py diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index eb69750..3e57ed5 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -28,6 +28,7 @@ from app.repositories import ( ProofRepository, UserRepository, AuditRepository, + DropdownRepository, ) router = APIRouter() @@ -486,3 +487,106 @@ async def list_users( ) for u in users ] + + +# Dropdown options endpoints +@router.get("/dropdown-options", response_model=DropdownOptionsResponse) +async def get_dropdown_options( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Get all dropdown options as hierarchical structure.""" + repo = DropdownRepository(db) + options = await repo.get_all_hierarchical() + return DropdownOptionsResponse(**options) + + +@router.post("/dropdown-options/channels", status_code=201) +async def add_channel( + name: str = Query(..., description="Channel name"), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Add a new channel.""" + repo = DropdownRepository(db) + await repo.add_channel(name) + await db.commit() + return {"message": f"Channel '{name}' added successfully"} + + +@router.post("/dropdown-options/channels/{channel}/sub-channels", status_code=201) +async def add_sub_channel( + channel: str, + name: str = Query(..., description="Sub-channel name"), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Add a sub-channel under a channel.""" + repo = DropdownRepository(db) + result = await repo.add_sub_channel(channel, name) + if not result: + raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found") + await db.commit() + return {"message": f"Sub-channel '{name}' added to '{channel}'"} + + +@router.post("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types", status_code=201) +async def add_proof_type( + channel: str, + sub_channel: str, + name: str = Query(..., description="Proof type name"), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Add a proof type under a sub-channel.""" + repo = DropdownRepository(db) + result = await repo.add_proof_type(channel, sub_channel, name) + if not result: + raise HTTPException(status_code=404, detail=f"Channel '{channel}' or sub-channel '{sub_channel}' not found") + await db.commit() + return {"message": f"Proof type '{name}' added to '{channel}/{sub_channel}'"} + + +@router.delete("/dropdown-options/channels/{channel}", status_code=204) +async def delete_channel( + channel: str, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Delete a channel and all its sub-channels and proof types.""" + repo = DropdownRepository(db) + success = await repo.remove_channel(channel) + if not success: + raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found") + await db.commit() + + +@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}", status_code=204) +async def delete_sub_channel( + channel: str, + sub_channel: str, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Delete a sub-channel and all its proof types.""" + repo = DropdownRepository(db) + success = await repo.remove_sub_channel(channel, sub_channel) + if not success: + raise HTTPException(status_code=404, detail=f"Sub-channel '{sub_channel}' not found in channel '{channel}'") + await db.commit() + + +@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types/{proof_type}", status_code=204) +async def delete_proof_type( + channel: str, + sub_channel: str, + proof_type: str, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Delete a proof type.""" + repo = DropdownRepository(db) + success = await repo.remove_proof_type(channel, sub_channel, proof_type) + if not success: + raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found") + await db.commit() diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index f4ba9f1..dbb9ef9 100755 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -2,10 +2,12 @@ from app.repositories.campaign_repository import CampaignRepository from app.repositories.proof_repository import ProofRepository from app.repositories.user_repository import UserRepository from app.repositories.audit_repository import AuditRepository +from app.repositories.dropdown_repository import DropdownRepository __all__ = [ "CampaignRepository", "ProofRepository", "UserRepository", "AuditRepository", + "DropdownRepository", ] diff --git a/backend/app/repositories/dropdown_repository.py b/backend/app/repositories/dropdown_repository.py new file mode 100644 index 0000000..cdc3c12 --- /dev/null +++ b/backend/app/repositories/dropdown_repository.py @@ -0,0 +1,202 @@ +"""Repository for dropdown options (channels, sub-channels, proof types).""" +import uuid +from typing import Optional +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.models import DropdownOption + + +class DropdownRepository: + """Repository for managing dropdown options.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_all_hierarchical(self) -> dict: + """ + Get all dropdown options as a hierarchical structure. + Returns: { channels: { channel_name: { sub_channel_name: [proof_types] } } } + """ + # Get all channels (top-level, no parent) + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.parent_id.is_(None) + ).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children)) + + result = await self.session.execute(stmt) + channels = result.scalars().all() + + # Build hierarchical structure + hierarchy: dict[str, dict[str, list[str]]] = {} + + for channel in channels: + channel_name = channel.value + hierarchy[channel_name] = {} + + for sub_channel in channel.children: + sub_channel_name = sub_channel.value + hierarchy[channel_name][sub_channel_name] = [ + pt.value for pt in sub_channel.children + ] + + return {"channels": hierarchy, "campaigns": []} # campaigns not used from dropdown options + + async def add_channel(self, name: str) -> DropdownOption: + """Add a new channel (top-level option).""" + option = DropdownOption( + option_type="channel", + value=name, + parent_id=None + ) + self.session.add(option) + await self.session.flush() + return option + + async def add_sub_channel(self, channel_name: str, sub_channel_name: str) -> Optional[DropdownOption]: + """Add a sub-channel under a channel.""" + # Find the parent channel + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.value == channel_name + ) + result = await self.session.execute(stmt) + channel = result.scalar_one_or_none() + + if not channel: + return None + + option = DropdownOption( + option_type="sub_channel", + value=sub_channel_name, + parent_id=channel.id + ) + self.session.add(option) + await self.session.flush() + return option + + async def add_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> Optional[DropdownOption]: + """Add a proof type under a sub-channel.""" + # Find the parent sub-channel by traversing from channel + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.value == channel_name + ) + result = await self.session.execute(stmt) + channel = result.scalar_one_or_none() + + if not channel: + return None + + # Find sub-channel + stmt = select(DropdownOption).where( + DropdownOption.option_type == "sub_channel", + DropdownOption.value == sub_channel_name, + DropdownOption.parent_id == channel.id + ) + result = await self.session.execute(stmt) + sub_channel = result.scalar_one_or_none() + + if not sub_channel: + return None + + option = DropdownOption( + option_type="proof_type", + value=proof_type_name, + parent_id=sub_channel.id + ) + self.session.add(option) + await self.session.flush() + return option + + async def remove_channel(self, channel_name: str) -> bool: + """Remove a channel and all its children (cascades).""" + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.value == channel_name + ).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children)) + + result = await self.session.execute(stmt) + channel = result.scalar_one_or_none() + + if not channel: + return False + + # Delete proof types, then sub-channels, then channel + for sub_channel in channel.children: + for proof_type in sub_channel.children: + await self.session.delete(proof_type) + await self.session.delete(sub_channel) + await self.session.delete(channel) + await self.session.flush() + return True + + async def remove_sub_channel(self, channel_name: str, sub_channel_name: str) -> bool: + """Remove a sub-channel and its proof types.""" + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.value == channel_name + ) + result = await self.session.execute(stmt) + channel = result.scalar_one_or_none() + + if not channel: + return False + + stmt = select(DropdownOption).where( + DropdownOption.option_type == "sub_channel", + DropdownOption.value == sub_channel_name, + DropdownOption.parent_id == channel.id + ).options(selectinload(DropdownOption.children)) + + result = await self.session.execute(stmt) + sub_channel = result.scalar_one_or_none() + + if not sub_channel: + return False + + # Delete proof types, then sub-channel + for proof_type in sub_channel.children: + await self.session.delete(proof_type) + await self.session.delete(sub_channel) + await self.session.flush() + return True + + async def remove_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> bool: + """Remove a proof type.""" + stmt = select(DropdownOption).where( + DropdownOption.option_type == "channel", + DropdownOption.value == channel_name + ) + result = await self.session.execute(stmt) + channel = result.scalar_one_or_none() + + if not channel: + return False + + stmt = select(DropdownOption).where( + DropdownOption.option_type == "sub_channel", + DropdownOption.value == sub_channel_name, + DropdownOption.parent_id == channel.id + ) + result = await self.session.execute(stmt) + sub_channel = result.scalar_one_or_none() + + if not sub_channel: + return False + + stmt = select(DropdownOption).where( + DropdownOption.option_type == "proof_type", + DropdownOption.value == proof_type_name, + DropdownOption.parent_id == sub_channel.id + ) + result = await self.session.execute(stmt) + proof_type = result.scalar_one_or_none() + + if not proof_type: + return False + + await self.session.delete(proof_type) + await self.session.flush() + return True diff --git a/frontend/App.tsx b/frontend/App.tsx index bae1c68..b17be51 100755 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -5,6 +5,7 @@ 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'; @@ -13,7 +14,7 @@ import { Analytics } from './components/Analytics'; import { Profile } from './components/Profile'; import { CopyGenAI } from './components/CopyGenAI'; import { Settings } from './components/Settings'; -import { Campaigns, initialCampaigns, initialCampaignProofs } from './components/Campaigns'; +import { Campaigns } from './components/Campaigns'; import { Auditing } from './components/Auditing'; import { Login } from './components/Login'; import { WIPReviewer } from './components/WIPReviewer'; @@ -35,113 +36,99 @@ const App: React.FC = () => { const [selectedCampaign, setSelectedCampaign] = useState(null); const [selectedProof, setSelectedProof] = useState(null); const [error, setError] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(true); - const [dropdownOptions, setDropdownOptions] = useState(() => { - try { - const storedOptions = localStorage.getItem('barclays_modcomms_dropdown_options_v3'); - if (storedOptions) { - return JSON.parse(storedOptions); - } - } catch (error) { - console.error('Error reading dropdown options from localStorage', error); + // Initialize API service with MSAL instance for authenticated requests + useEffect(() => { + if (msalInstance) { + apiService.setMsalInstance(msalInstance); } - return { - campaigns: ["Barclays Q4 Social"], - channels: { - "Social": { - "Meta": ["In-feed 1x1", "In-feed 4x5", "Reels static 9x16", "Stories Static 9x16", "In-feed 1x1 video", "9x16 reels", "9x16 stories"] - }, - "YouTube - (online video)": { - "Video": ["1x1", "16x9"] - }, - "Google - Performance Max": { - "Video": ["1x1", "9x16", "16x9"], - "Static image": ["1080x1080", "4x5 (1080x1350)", "1.91x1 (1200x628)", "Logo 1x1 (1080x1080)"] - }, - "Display": { - "Banner": ["300x600", "160x600", "970x250", "300x250"] - }, - ".co.uk Banner": { - "Web Banner Design + Static": ["720x540", "1316x740"] - }, - "Ad Copy": { - "Copy Document": ["Text"] - } - } + }, [msalInstance]); + + // Dropdown options now loaded from API + const [dropdownOptions, setDropdownOptions] = useState({ + campaigns: [], + channels: {} + }); + + // Load dropdown options from API when authenticated + useEffect(() => { + const loadDropdownOptions = async () => { + if (!isAuthenticated) return; + + try { + const options = await apiService.getDropdownOptions(); + setDropdownOptions({ + campaigns: options.campaigns || [], + channels: options.channels || {} + }); + } catch (error) { + console.error('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"] } + } + }); + } }; - }); + loadDropdownOptions(); + }, [isAuthenticated]); + + + // Campaigns and proofs now loaded from API instead of localStorage + const [campaigns, setCampaigns] = useState([]); + const [campaignProofs, setCampaignProofs] = useState>({}); + + // Load campaigns from API when authenticated useEffect(() => { - try { - localStorage.setItem('barclays_modcomms_dropdown_options_v3', JSON.stringify(dropdownOptions)); - } catch (error) { - console.error('Error saving dropdown options to localStorage', error); - } - }, [dropdownOptions]); + const loadCampaigns = async () => { + if (!isAuthenticated) return; - - const [campaigns, setCampaigns] = useState(() => { - try { - const storedCampaigns = localStorage.getItem('barclays_modcomms_campaigns_v3'); - return storedCampaigns ? JSON.parse(storedCampaigns) : initialCampaigns; - } catch (error) { - console.error('Error reading campaigns from localStorage', error); - return initialCampaigns; - } - }); - - const [campaignProofs, setCampaignProofs] = useState(() => { + setIsLoadingData(true); try { - const storedProofs = localStorage.getItem('barclays_modcomms_campaign_proofs_v3'); - return storedProofs ? JSON.parse(storedProofs) : initialCampaignProofs; + const response = await apiService.getCampaigns(); + setCampaigns(response.map(c => apiService.convertCampaignToFrontend(c))); } catch (error) { - console.error('Error reading campaign proofs from localStorage', error); - return initialCampaignProofs; + console.error('Failed to load campaigns:', error); + setError('Failed to load campaigns. Please try again.'); + } finally { + setIsLoadingData(false); } - }); + }; - const [flaggedItems, setFlaggedItems] = useState(() => { - try { - const storedFlags = localStorage.getItem('barclays_modcomms_flagged_items_v3'); - return storedFlags ? JSON.parse(storedFlags) : []; - } catch (error) { - console.error('Error reading flagged items from localStorage', error); - return []; - } - }); + loadCampaigns(); + }, [isAuthenticated]); - const [resolvedItems, setResolvedItems] = useState(() => { - try { - const storedResolutions = localStorage.getItem('barclays_modcomms_resolved_items_v3'); - return storedResolutions ? JSON.parse(storedResolutions) : []; - } catch (error) { - console.error('Error reading resolved items from localStorage', error); - return []; - } - }); + // Audit items now loaded from API instead of localStorage + const [flaggedItems, setFlaggedItems] = useState([]); + const [resolvedItems, setResolvedItems] = useState([]); + const [errorItems, setErrorItems] = useState([]); - const [errorItems, setErrorItems] = useState(() => { - try { - const storedErrors = localStorage.getItem('barclays_modcomms_error_items_v3'); - return storedErrors ? JSON.parse(storedErrors) : []; - } catch (error) { - console.error('Error reading error items from localStorage', error); - return []; - } - }); + // Load audit items from API when authenticated + useEffect(() => { + const loadAuditItems = async () => { + if (!isAuthenticated) return; - useEffect(() => { - try { - if (!localStorage.getItem('barclays_modcomms_campaigns_v3')) { - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(initialCampaigns)); - } - if (!localStorage.getItem('barclays_modcomms_campaign_proofs_v3')) { - localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(initialCampaignProofs)); - } - } catch (error) { - console.error('Error saving initial data to localStorage', error); - } - }, []); + 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. @@ -157,67 +144,21 @@ const App: React.FC = () => { } }, [campaignProofs, selectedCampaign, selectedProof]); - // New function to handle saving campaign proofs to avoid QuotaExceededError - const saveCampaignProofs = (proofs: any) => { - try { - // Deep clone to avoid mutating the state object used by React - const proofsToSave = JSON.parse(JSON.stringify(proofs)); - - // Iterate over all campaigns and their proofs - for (const campaignName in proofsToSave) { - if (Object.prototype.hasOwnProperty.call(proofsToSave, campaignName)) { - proofsToSave[campaignName].forEach((proof: any) => { - // For temporary placeholder proofs, remove the file object and progress - // as it cannot be stringified and is not needed for persistence. - if (proof.tempId) { - if ('file' in proof) delete proof.file; - if ('analysisProgress' in proof) delete proof.analysisProgress; - } - - // For versioned proofs, remove large non-SVG preview URLs - if (proof.versions) { - proof.versions.forEach((version: any) => { - if (version.proofPreviewUrl && !version.proofPreviewUrl.startsWith('data:image/svg+xml')) { - delete version.proofPreviewUrl; - } - }); - } - }); - } - } - - localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(proofsToSave)); - } catch (error) { - if ((error as DOMException).name === 'QuotaExceededError') { - console.error('LocalStorage quota exceeded. Could not save campaign proofs. The app might not persist data correctly across sessions until storage is cleared.', error); - setError('Could not save campaign changes, storage is full.'); - } else { - console.error('Error saving campaign proofs to localStorage', error); - } - } - }; - - const handleAddNewCampaign = (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => { - const newCampaign = { - ...campaignData, - agency: "OLIVER Agency", - agencyLead: "Steve O'Donoghue", - proofs: 0, - status: 'In Progress', - lastModified: new Date().toISOString().split('T')[0], - }; - - const updatedCampaigns = [...campaigns, newCampaign]; - const updatedCampaignProofs = { ...campaignProofs, [newCampaign.name]: [] }; - - setCampaigns(updatedCampaigns); - setCampaignProofs(updatedCampaignProofs); - + const handleAddNewCampaign = async (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => { try { - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); - saveCampaignProofs(updatedCampaignProofs); + const response = await apiService.createCampaign({ + name: campaignData.name, + workfront_id: campaignData.workfrontId, + client_lead: campaignData.clientLead, + brand_guidelines: campaignData.brandGuidelines, + }); + + const newCampaign = apiService.convertCampaignToFrontend(response); + setCampaigns(prev => [...prev, newCampaign]); + setCampaignProofs(prev => ({ ...prev, [newCampaign.name]: [] })); } catch (error) { - console.error('Error saving new campaign to localStorage', error); + console.error('Error creating campaign:', error); + setError('Failed to create campaign. Please try again.'); } }; @@ -230,27 +171,6 @@ const App: React.FC = () => { }); }; - const handleAddNewError = (errorData: Omit) => { - const newError: ErrorItem = { - ...errorData, - id: `err_${Date.now()}`, - timestamp: new Date().toISOString(), - submitter: "Steve O'Donoghue", // Hardcoded for prototype - submitAgency: "OLIVER Agency", // Hardcoded for prototype - }; - - setErrorItems(prevItems => { - const updatedErrors = [newError, ...prevItems]; - try { - localStorage.setItem('barclays_modcomms_error_items_v3', JSON.stringify(updatedErrors)); - } catch (error) { - console.error('Error saving error items to localStorage', error); - setError('Could not save the analysis error log due to storage limitations.'); - } - return updatedErrors; - }); - }; - const handleProofUploadForCampaign = async ( campaignName: string, file: File, @@ -260,6 +180,14 @@ const App: React.FC = () => { 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, @@ -277,7 +205,7 @@ const App: React.FC = () => { ...prevProofs, [campaignName]: [newProofPlaceholder, ...(prevProofs[campaignName] || [])] })); - + const handleAgentUpdate = (agentName: AgentName | 'Summary') => { setCampaignProofs(prevProofs => { const currentProofs = prevProofs[campaignName] || []; @@ -293,119 +221,56 @@ const App: React.FC = () => { }; try { - const feedback = await analyzeProof(file, handleAgentUpdate, msalInstance); - const previewUrl = await fileToDataUrl(file); + // Pass campaign context to persist proof in database + const result = await analyzeProof(file, handleAgentUpdate, msalInstance, { + campaignId: campaign._id, + proofName, + channel, + subChannel, + proofType, + }); - if (feedback.overallStatus === 'Analysis Error') { - const currentCampaignProofsList = campaignProofs[campaignName] || []; - const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId); - const version = existingProof ? (existingProof.versions[0]?.version || 0) + 1 : 1; - - handleAddNewError({ - campaignName, - proofName, - version, - errorSummary: feedback.leadAgentSummary, - }); + 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) + })); } - setCampaignProofs(prevCampaignProofs => { - const currentCampaignProofsList = prevCampaignProofs[campaignName] || []; - const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId); - - if (existingProof) { - // UPDATE PROOF (NEW VERSION) - const latestVersionNumber = existingProof.versions[0]?.version || 0; - const newVersionNumber = latestVersionNumber + 1; - const baseWorkfrontId = existingProof.versions.length > 0 - ? existingProof.versions[existingProof.versions.length - 1].workfrontId.split('-V')[0] - : `#WF_${Math.floor(10000 + Math.random() * 90000)}`; + // 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); + } - const newVersion = { - version: newVersionNumber, - timestamp: new Date().toISOString().split('T')[0], - workfrontId: `${baseWorkfrontId}-V${newVersionNumber}`, - proofPreviewUrl: previewUrl, - feedback: feedback, - overallStatus: feedback.overallStatus, - }; + // 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); + } - const updatedProof = { - ...existingProof, - overallStatus: feedback.overallStatus, - versions: [newVersion, ...existingProof.versions] - }; - - const updatedProofsList = currentCampaignProofsList - .filter(proof => proof.tempId !== tempId) - .map(proof => proof.proofName === proofName ? updatedProof : proof); - - const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList }; - - setCampaigns(prevCampaigns => { - const updatedCampaigns = prevCampaigns.map(p => - p.name === campaignName ? { ...p, lastModified: new Date().toISOString().split('T')[0] } : p - ); - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); - return updatedCampaigns; - }); - - saveCampaignProofs(finalCampaignProofs); - return finalCampaignProofs; - - } else { - // CREATE NEW PROOF - const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`; - const newProofWithVersion = { - proofName, - channel, - subChannel, - proofType, - status: 'completed', - overallStatus: feedback.overallStatus, - versions: [ - { - version: 1, - timestamp: new Date().toISOString().split('T')[0], - workfrontId: `${newWorkfrontId}-V1`, - proofPreviewUrl: previewUrl, - feedback: feedback, - overallStatus: feedback.overallStatus, - } - ] - }; - - const updatedProofsList = currentCampaignProofsList.map(proof => - proof.tempId === tempId ? newProofWithVersion : proof - ); - - const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList }; - - setCampaigns(prevCampaigns => { - const updatedCampaigns = prevCampaigns.map(p => - p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p - ); - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); - return updatedCampaigns; - }); - - saveCampaignProofs(finalCampaignProofs); - return finalCampaignProofs; - } - }); } catch (err) { console.error("Failed to upload and analyze proof:", err); setError("Failed to upload and analyze proof. Please try again."); - setCampaignProofs(prevProofs => { - const updatedProofs = { - ...prevProofs, - [campaignName]: prevProofs[campaignName].map(proof => - proof.tempId === tempId ? { ...proof, status: 'error' } : proof - ) - }; - saveCampaignProofs(updatedProofs); - return updatedProofs; - }); + setCampaignProofs(prevProofs => ({ + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? { ...proof, status: 'error' } : proof + ) + })); } }; @@ -417,6 +282,13 @@ const App: React.FC = () => { 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; + } + const { file, proofName, channel, subChannel, proofType } = proofToRetry; setCampaignProofs(prevProofs => ({ @@ -441,107 +313,120 @@ const App: React.FC = () => { }; try { - const feedback = await analyzeProof(file, handleAgentUpdateForRetry, msalInstance); - const previewUrl = await fileToDataUrl(file); - const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`; - - if (feedback.overallStatus === 'Analysis Error') { - handleAddNewError({ - campaignName: campaignName, - proofName: proofName, - version: 1, // Retry always creates a V1 - errorSummary: feedback.leadAgentSummary, - }); - } - - const newProofWithVersion = { + // Pass campaign context to persist proof in database + const result = await analyzeProof(file, handleAgentUpdateForRetry, msalInstance, { + campaignId: campaign._id, proofName, channel, subChannel, proofType, - status: 'completed', - overallStatus: feedback.overallStatus, - versions: [ - { - version: 1, - timestamp: new Date().toISOString().split('T')[0], - workfrontId: `${newWorkfrontId}-V1`, - proofPreviewUrl: previewUrl, - feedback: feedback, - overallStatus: feedback.overallStatus, - } - ] - }; - - const updatedCampaigns = campaigns.map(p => - p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p - ); - setCampaigns(updatedCampaigns); - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); - - setCampaignProofs(prevProofs => { - const updatedProofs = { - ...prevProofs, - [campaignName]: prevProofs[campaignName].map(proof => - proof.tempId === tempId ? newProofWithVersion : proof - ) - }; - saveCampaignProofs(updatedProofs); - return updatedProofs; }); + // 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 => { - const updatedProofs = { - ...prevProofs, - [campaignName]: prevProofs[campaignName].map(proof => - proof.tempId === tempId ? { ...proof, status: 'error' } : proof - ) - }; - saveCampaignProofs(updatedProofs); - return updatedProofs; - }); + setCampaignProofs(prevProofs => ({ + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? { ...proof, status: 'error' } : proof + ) + })); } }; - const handleCampaignStatusChange = (campaignName: string, newStatus: 'In Progress' | 'Completed') => { - const updatedCampaigns = campaigns.map(p => - p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString().split('T')[0] } : p - ); - setCampaigns(updatedCampaigns); + 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; + } + try { - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + await apiService.updateCampaign(campaign._id, { status: newStatus }); + // Optimistically update local state + setCampaigns(prev => prev.map(p => + p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString().split('T')[0] } : p + )); } catch (error) { - console.error('Error saving campaign status to localStorage', error); + console.error('Error updating campaign status:', error); + setError('Failed to update campaign status.'); } }; - const handleDeleteProof = (campaignName: string, proofName: string) => { - // Update campaign proofs - setCampaignProofs(prevProofs => { - const updatedProofsForCampaign = prevProofs[campaignName].filter( - proof => proof.proofName !== proofName - ); - const finalProofs = { ...prevProofs, [campaignName]: updatedProofsForCampaign }; - saveCampaignProofs(finalProofs); - return finalProofs; - }); + 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; + } - // Update campaigns list (proof count) - setCampaigns(prevCampaigns => { - const updatedCampaigns = prevCampaigns.map(p => - p.name === campaignName - ? { ...p, proofs: p.proofs > 0 ? p.proofs - 1 : 0, lastModified: new Date().toISOString().split('T')[0] } - : p - ); - localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); - return updatedCampaigns; - }); + 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 (UPDATED) --- + // --- 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 || {} + }); + } 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)) { @@ -555,96 +440,114 @@ const App: React.FC = () => { setDropdownOptions(prev => ({ ...prev, campaigns: prev.campaigns.filter(c => c !== value) })); }; - const handleAddChannel = (channel: string) => { - setDropdownOptions(prev => { - if (prev.channels[channel]) return prev; - return { - ...prev, - channels: { - ...prev.channels, - [channel]: {} - } - }; - }); + 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 = (channel: string) => { - setDropdownOptions(prev => { + 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 = (channel: string, subChannel: string) => { - setDropdownOptions(prev => { - const channelData = prev.channels[channel] || {}; - if (channelData[subChannel]) return prev; - - return { - ...prev, - channels: { - ...prev.channels, - [channel]: { - ...channelData, - [subChannel]: [] - } - } - }; - }); + 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 = (channel: string, subChannel: string) => { - setDropdownOptions(prev => { + 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 - } - }; - }); + return { ...prev, channels: { ...prev.channels, [channel]: channelData } }; + }); + } catch (error) { + console.error('Error removing sub-channel:', error); + setError('Failed to remove sub-channel.'); + } }; - const handleAddProofType = (channel: string, subChannel: string, proofType: string) => { - setDropdownOptions(prev => { + 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] || []; - if (currentTypes.includes(proofType)) return prev; - return { - ...prev, - channels: { - ...prev.channels, - [channel]: { - ...channelData, - [subChannel]: [...currentTypes, proofType].sort() - } + ...prev, + channels: { + ...prev.channels, + [channel]: { + ...channelData, + [subChannel]: currentTypes.filter(t => t !== proofType) } + } }; - }); - }; - - const handleRemoveProofType = (channel: string, subChannel: string, proofType: string) => { - 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.'); + } }; @@ -654,8 +557,29 @@ const App: React.FC = () => { setSelectedProof(null); }; - const handleSelectCampaign = (campaignName: string) => { + 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) => { @@ -671,43 +595,47 @@ const App: React.FC = () => { setSelectedProof(null); }; - const handleFlagSubmit = (flagData: Omit) => { - const newFlag: FlaggedItem = { - ...flagData, - id: `flag_${Date.now()}`, - timestamp: new Date().toISOString(), - submitter: "Steve O'Donoghue", // Hardcoded for prototype - submitAgency: "OLIVER Agency", // Hardcoded for prototype - }; - - const updatedFlags = [newFlag, ...flaggedItems]; - setFlaggedItems(updatedFlags); + const handleFlagSubmit = async (flagData: Omit) => { + // 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 { - localStorage.setItem('barclays_modcomms_flagged_items_v3', JSON.stringify(updatedFlags)); + 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 saving flagged items to localStorage', error); - setError('Could not save your feedback due to storage limitations.'); + console.error('Error flagging proof version:', error); + setError('Failed to submit flag. Please try again.'); } }; - - const handleResolveSubmit = (resolveData: Omit) => { - const newResolution: ResolvedItem = { - ...resolveData, - id: `res_${Date.now()}`, - timestamp: new Date().toISOString(), - submitter: "Steve O'Donoghue", // Hardcoded for prototype - submitAgency: "OLIVER Agency", // Hardcoded for prototype - }; - - const updatedResolutions = [newResolution, ...resolvedItems]; - setResolvedItems(updatedResolutions); - + + const handleResolveSubmit = async (resolveData: Omit) => { + // 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 { - localStorage.setItem('barclays_modcomms_resolved_items_v3', JSON.stringify(updatedResolutions)); + 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 saving resolved items to localStorage', error); - setError('Could not save your resolution due to storage limitations.'); + console.error('Error resolving proof version:', error); + setError('Failed to submit resolution. Please try again.'); } }; diff --git a/frontend/components/Analytics.tsx b/frontend/components/Analytics.tsx index 1e2e7ca..8e1564b 100755 --- a/frontend/components/Analytics.tsx +++ b/frontend/components/Analytics.tsx @@ -1,17 +1,12 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { UploadIcon } from './icons/UploadIcon'; import { TrendingUpIcon } from './icons/TrendingUpIcon'; import { BugIcon } from './icons/BugIcon'; import { ClockIcon } from './icons/ClockIcon'; import { LightbulbIcon } from './icons/LightbulbIcon'; +import apiService, { AnalyticsResponse } from '../services/apiService'; -const stats = [ - { name: 'Proofs Uploaded', value: '57', icon: UploadIcon }, - { name: 'Pass Rate', value: '76%', icon: TrendingUpIcon }, - { name: 'Issues Found', value: '34', icon: BugIcon }, - { name: 'Time Saved', value: '93 hours', icon: ClockIcon }, -]; - +// Agent performance is still static for now - would need separate API const agentPerformance = [ { name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' }, { name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' }, @@ -34,6 +29,35 @@ const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend } }; export const Analytics: React.FC = () => { + const [analytics, setAnalytics] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadAnalytics = async () => { + try { + const data = await apiService.getAnalytics(); + setAnalytics(data); + } catch (error) { + console.error('Failed to load analytics:', error); + } finally { + setIsLoading(false); + } + }; + loadAnalytics(); + }, []); + + // Calculate stats from API data + const passRate = analytics && analytics.total_reviews > 0 + ? Math.round((analytics.passed / analytics.total_reviews) * 100) + : 0; + + const stats = [ + { name: 'Proofs Reviewed', value: analytics?.total_reviews?.toString() || '0', icon: UploadIcon }, + { name: 'Pass Rate', value: `${passRate}%`, icon: TrendingUpIcon }, + { name: 'Failed Reviews', value: analytics?.failed?.toString() || '0', icon: BugIcon }, + { name: 'Legal Review Required', value: analytics?.legal_review?.toString() || '0', icon: ClockIcon }, + ]; + return (
diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index 499b526..ee84b75 100755 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -311,6 +311,52 @@ class ApiService { timestamp: item.created_at, }; } + + // Dropdown options endpoints + async getDropdownOptions(): Promise { + return this.fetch('/dropdown-options'); + } + + async addChannel(name: string): Promise { + await this.fetch(`/dropdown-options/channels?name=${encodeURIComponent(name)}`, { + method: 'POST', + }); + } + + async addSubChannel(channel: string, name: string): Promise { + await this.fetch(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels?name=${encodeURIComponent(name)}`, { + method: 'POST', + }); + } + + async addProofType(channel: string, subChannel: string, name: string): Promise { + await this.fetch(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types?name=${encodeURIComponent(name)}`, { + method: 'POST', + }); + } + + async deleteChannel(channel: string): Promise { + await this.fetch(`/dropdown-options/channels/${encodeURIComponent(channel)}`, { + method: 'DELETE', + }); + } + + async deleteSubChannel(channel: string, subChannel: string): Promise { + await this.fetch(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}`, { + method: 'DELETE', + }); + } + + async deleteProofType(channel: string, subChannel: string, proofType: string): Promise { + await this.fetch(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types/${encodeURIComponent(proofType)}`, { + method: 'DELETE', + }); + } +} + +export interface DropdownOptionsResponse { + campaigns: string[]; + channels: Record>; } export const apiService = new ApiService();